testplane 9.1.1 → 9.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/build/package.json +2 -2
- package/build/src/base-testplane.d.ts +17 -1
- package/build/src/base-testplane.js +43 -9
- package/build/src/base-testplane.js.map +1 -1
- package/build/src/browser/browser.d.ts +3 -0
- package/build/src/browser/browser.js +4 -2
- package/build/src/browser/browser.js.map +1 -1
- package/build/src/browser/cdp/selectivity/testplane-selectivity.js +19 -19
- package/build/src/browser/cdp/selectivity/testplane-selectivity.js.map +1 -1
- package/build/src/browser/client-scripts/screen-shooter/build/bundle.compat.js +2 -2
- package/build/src/browser/client-scripts/screen-shooter/build/bundle.native.js +2 -2
- package/build/src/browser/client-scripts/screen-shooter/tsc-out/client-scripts/screen-shooter/implementation.js +52 -84
- package/build/src/browser/client-scripts/screen-shooter/tsc-out/client-scripts/screen-shooter/operations.js +4 -1
- package/build/src/browser/commands/assert-view/index.js +25 -2
- package/build/src/browser/commands/assert-view/index.js.map +1 -1
- package/build/src/browser/existing-browser.js +8 -1
- package/build/src/browser/existing-browser.js.map +1 -1
- package/build/src/browser/history/index.d.ts +4 -2
- package/build/src/browser/history/index.js +146 -67
- package/build/src/browser/history/index.js.map +1 -1
- package/build/src/browser/new-browser.js +1 -1
- package/build/src/browser/new-browser.js.map +1 -1
- package/build/src/browser/screen-shooter/elements-screen-shooter.d.ts +5 -0
- package/build/src/browser/screen-shooter/elements-screen-shooter.js +102 -55
- package/build/src/browser/screen-shooter/elements-screen-shooter.js.map +1 -1
- package/build/src/browser/screen-shooter/viewport-screen-shooter.d.ts +4 -0
- package/build/src/browser/screen-shooter/viewport-screen-shooter.js +29 -7
- package/build/src/browser/screen-shooter/viewport-screen-shooter.js.map +1 -1
- package/build/src/browser-pool/basic-pool.d.ts +4 -3
- package/build/src/browser-pool/basic-pool.js +39 -7
- package/build/src/browser-pool/basic-pool.js.map +1 -1
- package/build/src/browser-pool/caching-pool.d.ts +3 -2
- package/build/src/browser-pool/caching-pool.js +19 -7
- package/build/src/browser-pool/caching-pool.js.map +1 -1
- package/build/src/browser-pool/index.d.ts +2 -1
- package/build/src/browser-pool/index.js +8 -4
- package/build/src/browser-pool/index.js.map +1 -1
- package/build/src/browser-pool/limited-pool.d.ts +5 -1
- package/build/src/browser-pool/limited-pool.js +42 -4
- package/build/src/browser-pool/limited-pool.js.map +1 -1
- package/build/src/browser-pool/per-browser-limited-pool.d.ts +2 -2
- package/build/src/browser-pool/per-browser-limited-pool.js +2 -1
- package/build/src/browser-pool/per-browser-limited-pool.js.map +1 -1
- package/build/src/browser-pool/types.d.ts +8 -0
- package/build/src/cli/commands/config/index.js +7 -3
- package/build/src/cli/commands/config/index.js.map +1 -1
- package/build/src/cli/commands/install-deps/index.js +46 -38
- package/build/src/cli/commands/install-deps/index.js.map +1 -1
- package/build/src/cli/commands/list-browsers/index.js +15 -11
- package/build/src/cli/commands/list-browsers/index.js.map +1 -1
- package/build/src/cli/commands/list-tests/index.js +25 -21
- package/build/src/cli/commands/list-tests/index.js.map +1 -1
- package/build/src/cli/commands/selectivity-merge-dumps/index.js +12 -5
- package/build/src/cli/commands/selectivity-merge-dumps/index.js.map +1 -1
- package/build/src/cli/index.js +10 -6
- package/build/src/cli/index.js.map +1 -1
- package/build/src/config/defaults.d.ts +5 -0
- package/build/src/config/defaults.js +4 -0
- package/build/src/config/defaults.js.map +1 -1
- package/build/src/config/index.d.ts +6 -4
- package/build/src/config/index.js +60 -19
- package/build/src/config/index.js.map +1 -1
- package/build/src/config/options.js +26 -0
- package/build/src/config/options.js.map +1 -1
- package/build/src/config/types.d.ts +19 -0
- package/build/src/constants/process-messages.d.ts +3 -0
- package/build/src/constants/process-messages.js +4 -1
- package/build/src/constants/process-messages.js.map +1 -1
- package/build/src/events/async-emitter/index.d.ts +38 -0
- package/build/src/events/async-emitter/index.js +248 -5
- package/build/src/events/async-emitter/index.js.map +1 -1
- package/build/src/events/index.d.ts +3 -0
- package/build/src/events/index.js +1 -0
- package/build/src/events/index.js.map +1 -1
- package/build/src/index.d.ts +2 -1
- package/build/src/profiler/analysis/aggregator.d.ts +19 -0
- package/build/src/profiler/analysis/aggregator.js +229 -0
- package/build/src/profiler/analysis/aggregator.js.map +1 -0
- package/build/src/profiler/analysis/analyzers.d.ts +10 -0
- package/build/src/profiler/analysis/analyzers.js +1228 -0
- package/build/src/profiler/analysis/analyzers.js.map +1 -0
- package/build/src/profiler/analysis/compact.d.ts +5 -0
- package/build/src/profiler/analysis/compact.js +46 -0
- package/build/src/profiler/analysis/compact.js.map +1 -0
- package/build/src/profiler/analysis/finding-order.d.ts +4 -0
- package/build/src/profiler/analysis/finding-order.js +28 -0
- package/build/src/profiler/analysis/finding-order.js.map +1 -0
- package/build/src/profiler/analysis/outliers.d.ts +6 -0
- package/build/src/profiler/analysis/outliers.js +49 -0
- package/build/src/profiler/analysis/outliers.js.map +1 -0
- package/build/src/profiler/analysis/policy-v1.d.ts +71 -0
- package/build/src/profiler/analysis/policy-v1.js +43 -0
- package/build/src/profiler/analysis/policy-v1.js.map +1 -0
- package/build/src/profiler/collectors/module-graph.d.ts +14 -0
- package/build/src/profiler/collectors/module-graph.js +99 -0
- package/build/src/profiler/collectors/module-graph.js.map +1 -0
- package/build/src/profiler/collectors/pool-observer.d.ts +9 -0
- package/build/src/profiler/collectors/pool-observer.js +40 -0
- package/build/src/profiler/collectors/pool-observer.js.map +1 -0
- package/build/src/profiler/manager.d.ts +55 -0
- package/build/src/profiler/manager.js +343 -0
- package/build/src/profiler/manager.js.map +1 -0
- package/build/src/profiler/output/console.d.ts +6 -0
- package/build/src/profiler/output/console.js +443 -0
- package/build/src/profiler/output/console.js.map +1 -0
- package/build/src/profiler/output/json.d.ts +2 -0
- package/build/src/profiler/output/json.js +24 -0
- package/build/src/profiler/output/json.js.map +1 -0
- package/build/src/profiler/retention/operation-store.d.ts +22 -0
- package/build/src/profiler/retention/operation-store.js +110 -0
- package/build/src/profiler/retention/operation-store.js.map +1 -0
- package/build/src/profiler/retention/policy-v1.d.ts +22 -0
- package/build/src/profiler/retention/policy-v1.js +49 -0
- package/build/src/profiler/retention/policy-v1.js.map +1 -0
- package/build/src/profiler/retention/result-budget.d.ts +2 -0
- package/build/src/profiler/retention/result-budget.js +117 -0
- package/build/src/profiler/retention/result-budget.js.map +1 -0
- package/build/src/profiler/retention/serialized-budget.d.ts +5 -0
- package/build/src/profiler/retention/serialized-budget.js +58 -0
- package/build/src/profiler/retention/serialized-budget.js.map +1 -0
- package/build/src/profiler/retention/streaming-statistics.d.ts +38 -0
- package/build/src/profiler/retention/streaming-statistics.js +191 -0
- package/build/src/profiler/retention/streaming-statistics.js.map +1 -0
- package/build/src/profiler/retention/top-k.d.ts +13 -0
- package/build/src/profiler/retention/top-k.js +57 -0
- package/build/src/profiler/retention/top-k.js.map +1 -0
- package/build/src/profiler/runtime/async-activity.d.ts +19 -0
- package/build/src/profiler/runtime/async-activity.js +113 -0
- package/build/src/profiler/runtime/async-activity.js.map +1 -0
- package/build/src/profiler/runtime/bootstrap-probe.d.ts +42 -0
- package/build/src/profiler/runtime/bootstrap-probe.js +91 -0
- package/build/src/profiler/runtime/bootstrap-probe.js.map +1 -0
- package/build/src/profiler/runtime/clock.d.ts +22 -0
- package/build/src/profiler/runtime/clock.js +15 -0
- package/build/src/profiler/runtime/clock.js.map +1 -0
- package/build/src/profiler/runtime/event-observer.d.ts +4 -0
- package/build/src/profiler/runtime/event-observer.js +142 -0
- package/build/src/profiler/runtime/event-observer.js.map +1 -0
- package/build/src/profiler/runtime/noop.d.ts +20 -0
- package/build/src/profiler/runtime/noop.js +37 -0
- package/build/src/profiler/runtime/noop.js.map +1 -0
- package/build/src/profiler/runtime/runtime.d.ts +95 -0
- package/build/src/profiler/runtime/runtime.js +1343 -0
- package/build/src/profiler/runtime/runtime.js.map +1 -0
- package/build/src/profiler/runtime/types.d.ts +88 -0
- package/build/src/profiler/runtime/types.js +3 -0
- package/build/src/profiler/runtime/types.js.map +1 -0
- package/build/src/profiler/sanitize.d.ts +15 -0
- package/build/src/profiler/sanitize.js +194 -0
- package/build/src/profiler/sanitize.js.map +1 -0
- package/build/src/profiler/schema/index.d.ts +1 -0
- package/build/src/profiler/schema/index.js +18 -0
- package/build/src/profiler/schema/index.js.map +1 -0
- package/build/src/profiler/schema/v1.d.ts +281 -0
- package/build/src/profiler/schema/v1.js +3 -0
- package/build/src/profiler/schema/v1.js.map +1 -0
- package/build/src/runner/browser-env/vite/browser-modules/mocha/index.js +30 -0
- package/build/src/runner/browser-env/vite/browser-modules/mocha/index.js.map +1 -1
- package/build/src/runner/browser-env/vite/browser-modules/tsconfig.tsbuildinfo +1 -1
- package/build/src/runner/browser-env/vite/browser-modules/types.d.ts +31 -6
- package/build/src/runner/browser-env/vite/browser-modules/types.js +8 -8
- package/build/src/runner/browser-env/vite/browser-modules/types.js.map +1 -1
- package/build/src/runner/browser-env/vite/plugins/generate-index-html.d.ts +7 -1
- package/build/src/runner/browser-env/vite/plugins/generate-index-html.js +28 -6
- package/build/src/runner/browser-env/vite/plugins/generate-index-html.js.map +1 -1
- package/build/src/runner/browser-env/vite/server.js +17 -1
- package/build/src/runner/browser-env/vite/server.js.map +1 -1
- package/build/src/runner/browser-env/vite/socket.js +4 -0
- package/build/src/runner/browser-env/vite/socket.js.map +1 -1
- package/build/src/runner/browser-env/vite/types.d.ts +9 -7
- package/build/src/runner/browser-env/vite/types.js +8 -8
- package/build/src/runner/browser-env/vite/types.js.map +1 -1
- package/build/src/runner/browser-env/vite/utils.js +9 -2
- package/build/src/runner/browser-env/vite/utils.js.map +1 -1
- package/build/src/runner/browser-runner.d.ts +3 -1
- package/build/src/runner/browser-runner.js +4 -2
- package/build/src/runner/browser-runner.js.map +1 -1
- package/build/src/runner/index.d.ts +3 -1
- package/build/src/runner/index.js +7 -4
- package/build/src/runner/index.js.map +1 -1
- package/build/src/runner/test-runner/index.d.ts +2 -1
- package/build/src/runner/test-runner/index.js +2 -2
- package/build/src/runner/test-runner/index.js.map +1 -1
- package/build/src/runner/test-runner/insistant-test-runner.d.ts +2 -1
- package/build/src/runner/test-runner/insistant-test-runner.js +3 -2
- package/build/src/runner/test-runner/insistant-test-runner.js.map +1 -1
- package/build/src/runner/test-runner/regular-test-runner.d.ts +9 -3
- package/build/src/runner/test-runner/regular-test-runner.js +77 -16
- package/build/src/runner/test-runner/regular-test-runner.js.map +1 -1
- package/build/src/signal-handler.js +14 -5
- package/build/src/signal-handler.js.map +1 -1
- package/build/src/test-reader/index.d.ts +2 -1
- package/build/src/test-reader/index.js +19 -10
- package/build/src/test-reader/index.js.map +1 -1
- package/build/src/test-reader/mocha-reader/index.d.ts +2 -1
- package/build/src/test-reader/mocha-reader/index.js +41 -24
- package/build/src/test-reader/mocha-reader/index.js.map +1 -1
- package/build/src/test-reader/mocha-reader/tree-builder-decorator.js +2 -2
- package/build/src/test-reader/mocha-reader/tree-builder-decorator.js.map +1 -1
- package/build/src/test-reader/mocha-reader/utils.d.ts +2 -0
- package/build/src/test-reader/mocha-reader/utils.js +18 -2
- package/build/src/test-reader/mocha-reader/utils.js.map +1 -1
- package/build/src/test-reader/test-object/hook.d.ts +4 -2
- package/build/src/test-reader/test-object/hook.js +3 -1
- package/build/src/test-reader/test-object/hook.js.map +1 -1
- package/build/src/test-reader/test-parser.d.ts +2 -1
- package/build/src/test-reader/test-parser.js +48 -4
- package/build/src/test-reader/test-parser.js.map +1 -1
- package/build/src/testplane.d.ts +6 -2
- package/build/src/testplane.js +82 -27
- package/build/src/testplane.js.map +1 -1
- package/build/src/types/index.d.ts +4 -1
- package/build/src/types/index.js.map +1 -1
- package/build/src/utils/exit-code.d.ts +1 -0
- package/build/src/utils/exit-code.js +28 -0
- package/build/src/utils/exit-code.js.map +1 -0
- package/build/src/utils/module-observer-registry.d.ts +43 -0
- package/build/src/utils/module-observer-registry.js +257 -0
- package/build/src/utils/module-observer-registry.js.map +1 -0
- package/build/src/utils/processor.js +1 -0
- package/build/src/utils/processor.js.map +1 -1
- package/build/src/utils/workers-registry.d.ts +48 -1
- package/build/src/utils/workers-registry.js +162 -4
- package/build/src/utils/workers-registry.js.map +1 -1
- package/build/src/worker/browser-env/runner/test-runner/execution-thread.js +15 -1
- package/build/src/worker/browser-env/runner/test-runner/execution-thread.js.map +1 -1
- package/build/src/worker/browser-env/runner/test-runner/index.js +20 -0
- package/build/src/worker/browser-env/runner/test-runner/index.js.map +1 -1
- package/build/src/worker/index.js +39 -1
- package/build/src/worker/index.js.map +1 -1
- package/build/src/worker/runner/browser-pool.d.ts +3 -2
- package/build/src/worker/runner/browser-pool.js +5 -3
- package/build/src/worker/runner/browser-pool.js.map +1 -1
- package/build/src/worker/runner/caching-test-parser.d.ts +6 -1
- package/build/src/worker/runner/caching-test-parser.js +40 -4
- package/build/src/worker/runner/caching-test-parser.js.map +1 -1
- package/build/src/worker/runner/index.d.ts +10 -9
- package/build/src/worker/runner/index.js +52 -6
- package/build/src/worker/runner/index.js.map +1 -1
- package/build/src/worker/runner/sequence-test-parser.d.ts +4 -1
- package/build/src/worker/runner/sequence-test-parser.js +17 -3
- package/build/src/worker/runner/sequence-test-parser.js.map +1 -1
- package/build/src/worker/runner/simple-test-parser.d.ts +3 -1
- package/build/src/worker/runner/simple-test-parser.js +4 -2
- package/build/src/worker/runner/simple-test-parser.js.map +1 -1
- package/build/src/worker/runner/test-runner/execution-thread.d.ts +20 -2
- package/build/src/worker/runner/test-runner/execution-thread.js +37 -2
- package/build/src/worker/runner/test-runner/execution-thread.js.map +1 -1
- package/build/src/worker/runner/test-runner/hook-runner.d.ts +1 -1
- package/build/src/worker/runner/test-runner/hook-runner.js +4 -4
- package/build/src/worker/runner/test-runner/hook-runner.js.map +1 -1
- package/build/src/worker/runner/test-runner/index.d.ts +8 -7
- package/build/src/worker/runner/test-runner/index.js +36 -25
- package/build/src/worker/runner/test-runner/index.js.map +1 -1
- package/build/src/worker/runner/test-runner/types.d.ts +8 -1
- package/build/src/worker/testplane-facade.js +63 -4
- package/build/src/worker/testplane-facade.js.map +1 -1
- package/build/src/worker/testplane.d.ts +6 -0
- package/build/src/worker/testplane.js +7 -1
- package/build/src/worker/testplane.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,1228 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runAnalyzers = void 0;
|
|
4
|
+
const aggregator_1 = require("./aggregator");
|
|
5
|
+
const finding_order_1 = require("./finding-order");
|
|
6
|
+
const outliers_1 = require("./outliers");
|
|
7
|
+
const policy_v1_1 = require("./policy-v1");
|
|
8
|
+
const createDefaultAnalyzers = () => [
|
|
9
|
+
longRunAnalyzer,
|
|
10
|
+
majorPhaseAnalyzer,
|
|
11
|
+
testDiscoveryAnalyzer,
|
|
12
|
+
unattributedAnalyzer,
|
|
13
|
+
listenerAnalyzer,
|
|
14
|
+
testFileAnalyzer,
|
|
15
|
+
slowTestAnalyzer,
|
|
16
|
+
hookAnalyzer,
|
|
17
|
+
commandAnalyzer,
|
|
18
|
+
moduleDependencyAnalyzer,
|
|
19
|
+
workerCapacityAnalyzer,
|
|
20
|
+
sessionConcurrencyAnalyzer,
|
|
21
|
+
resourceSaturationAnalyzer,
|
|
22
|
+
];
|
|
23
|
+
const runAnalyzers = (profile, analyzers = createDefaultAnalyzers()) => {
|
|
24
|
+
const findings = [];
|
|
25
|
+
for (const analyzer of analyzers) {
|
|
26
|
+
if (profile.level < analyzer.minLevel) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
findings.push(...analyzer.analyze(profile));
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
profile.errors.push({
|
|
34
|
+
stage: `analysis.${analyzer.id}`,
|
|
35
|
+
message: String(error?.message ?? error).slice(0, 1000),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return findings
|
|
40
|
+
.sort(finding_order_1.compareFindings)
|
|
41
|
+
.slice(0, 50)
|
|
42
|
+
.map((finding, index) => ({ ...finding, id: `${finding.analyzer.id}:${index + 1}` }));
|
|
43
|
+
};
|
|
44
|
+
exports.runAnalyzers = runAnalyzers;
|
|
45
|
+
const TEST_DISCOVERY_PHASE = "testplane.phase.test-discovery";
|
|
46
|
+
const NON_ACTIONABLE_PHASES = new Set([
|
|
47
|
+
"testplane.phase.execution",
|
|
48
|
+
"testplane.phase.unattributed",
|
|
49
|
+
TEST_DISCOVERY_PHASE,
|
|
50
|
+
]);
|
|
51
|
+
const longRunAnalyzer = {
|
|
52
|
+
id: "long-run-v1",
|
|
53
|
+
minLevel: 1,
|
|
54
|
+
analyze: profile => {
|
|
55
|
+
if (profile.operation !== "run" || profile.durationMs <= policy_v1_1.ANALYSIS_POLICY_V1.longRun.thresholdMs) {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
const hostCpu95thPercentile = sampledAggregateValue(profile, "host.cpuUtilization", "p95WallMs", {
|
|
59
|
+
process: "host",
|
|
60
|
+
});
|
|
61
|
+
const isHostCpuHigh = hostCpu95thPercentile !== undefined &&
|
|
62
|
+
hostCpu95thPercentile >= policy_v1_1.ANALYSIS_POLICY_V1.hostCpu.high95thPercentile;
|
|
63
|
+
const root = profile.timeline.find(operation => operation.kind === "testplane.operation");
|
|
64
|
+
const hostCpuObservation = hostCpu95thPercentile === undefined
|
|
65
|
+
? " Host CPU utilization data was unavailable."
|
|
66
|
+
: ` Host CPU p95 was ${formatPercent(hostCpu95thPercentile)}, ${isHostCpuHigh ? "at or above" : "below"} the ${formatPercent(policy_v1_1.ANALYSIS_POLICY_V1.hostCpu.high95thPercentile)} high-load threshold.`;
|
|
67
|
+
const evidence = [
|
|
68
|
+
{
|
|
69
|
+
metric: "wall",
|
|
70
|
+
value: profile.durationMs,
|
|
71
|
+
unit: "ms",
|
|
72
|
+
threshold: policy_v1_1.ANALYSIS_POLICY_V1.longRun.thresholdMs,
|
|
73
|
+
...(root ? { operationId: root.id } : {}),
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
metric: "cpuState",
|
|
77
|
+
value: isHostCpuHigh ? "high" : hostCpu95thPercentile === undefined ? "unavailable" : "headroom",
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
if (hostCpu95thPercentile !== undefined) {
|
|
81
|
+
evidence.push({
|
|
82
|
+
metric: "hostCpuP95",
|
|
83
|
+
value: hostCpu95thPercentile,
|
|
84
|
+
unit: "ratio",
|
|
85
|
+
threshold: policy_v1_1.ANALYSIS_POLICY_V1.hostCpu.high95thPercentile,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return [
|
|
89
|
+
simpleFinding({
|
|
90
|
+
analyzerId: "long-run-v1",
|
|
91
|
+
category: "run-duration",
|
|
92
|
+
observation: `The full test run took ${formatMs(profile.durationMs)}, longer than the ${policy_v1_1.ANALYSIS_POLICY_V1.longRun.thresholdMs / 60000}-minute threshold.${hostCpuObservation}`,
|
|
93
|
+
evidence,
|
|
94
|
+
action: longRunAction(hostCpu95thPercentile),
|
|
95
|
+
confidence: "high",
|
|
96
|
+
operationIds: root ? [root.id] : [],
|
|
97
|
+
}),
|
|
98
|
+
];
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
function longRunAction(hostCpu95thPercentile) {
|
|
102
|
+
if (hostCpu95thPercentile === undefined) {
|
|
103
|
+
return "Host CPU data was unavailable or insufficient. Check host CPU and browser-grid capacity first; if both have headroom, increase browser-session parallelism by raising sessionsPerBrowser.";
|
|
104
|
+
}
|
|
105
|
+
if (hostCpu95thPercentile >= policy_v1_1.ANALYSIS_POLICY_V1.hostCpu.high95thPercentile) {
|
|
106
|
+
return "Use @testplane/chunks to split the suite across CI jobs running in parallel on separate available hardware. The plugin selects deterministic chunks but does not orchestrate the jobs; sequential chunks will not reduce total time.";
|
|
107
|
+
}
|
|
108
|
+
return "Increase browser-session parallelism where capacity allows by raising sessionsPerBrowser. Compare the next run and stop before browser-grid or host saturation.";
|
|
109
|
+
}
|
|
110
|
+
const majorPhaseAnalyzer = {
|
|
111
|
+
id: "major-phase-v1",
|
|
112
|
+
minLevel: 1,
|
|
113
|
+
analyze: profile => profile.timeline
|
|
114
|
+
.filter(operation => operation.kind.startsWith("testplane.phase.") && !NON_ACTIONABLE_PHASES.has(operation.kind))
|
|
115
|
+
.filter(operation => isSignificantPhase(profile, operation))
|
|
116
|
+
.map(operation => majorPhaseFinding(profile, operation)),
|
|
117
|
+
};
|
|
118
|
+
const testDiscoveryAnalyzer = {
|
|
119
|
+
id: "test-discovery-v1",
|
|
120
|
+
minLevel: 1,
|
|
121
|
+
analyze: profile => profile.timeline
|
|
122
|
+
.filter(operation => operation.kind === TEST_DISCOVERY_PHASE &&
|
|
123
|
+
operation.process.type === "master" &&
|
|
124
|
+
isSignificantPhase(profile, operation))
|
|
125
|
+
.map(operation => testDiscoveryFinding(profile, operation)),
|
|
126
|
+
};
|
|
127
|
+
function isSignificantPhase(profile, operation) {
|
|
128
|
+
return (operation.timing.wallMs >= policy_v1_1.ANALYSIS_POLICY_V1.majorPhase.minWallMs &&
|
|
129
|
+
operation.timing.wallMs / profile.durationMs >= policy_v1_1.ANALYSIS_POLICY_V1.majorPhase.minRunShare);
|
|
130
|
+
}
|
|
131
|
+
const unattributedAnalyzer = {
|
|
132
|
+
id: "unattributed-v1",
|
|
133
|
+
minLevel: 1,
|
|
134
|
+
analyze: profile => profile.timeline
|
|
135
|
+
.filter(operation => operation.kind === "testplane.phase.unattributed")
|
|
136
|
+
.filter(operation => operation.attributes.retentionAffected !== true)
|
|
137
|
+
.filter(operation => {
|
|
138
|
+
const parent = profile.timeline.find(candidate => candidate.id === operation.parentId);
|
|
139
|
+
return (operation.timing.wallMs >= policy_v1_1.ANALYSIS_POLICY_V1.unattributed.minWallMs &&
|
|
140
|
+
operation.timing.wallMs / (parent?.timing.wallMs || profile.durationMs) >=
|
|
141
|
+
policy_v1_1.ANALYSIS_POLICY_V1.unattributed.minShare);
|
|
142
|
+
})
|
|
143
|
+
.map(operation => {
|
|
144
|
+
const parent = profile.timeline.find(candidate => candidate.id === operation.parentId);
|
|
145
|
+
const location = parent ? ` inside \`${parent.name}\`` : "";
|
|
146
|
+
const gap = parent ? largestUnattributedGap(profile, parent) : undefined;
|
|
147
|
+
const gapObservation = unattributedGapObservation(gap);
|
|
148
|
+
const gapEvidence = gap
|
|
149
|
+
? [
|
|
150
|
+
{
|
|
151
|
+
metric: "largestUnattributedInterval",
|
|
152
|
+
value: gap.wallMs,
|
|
153
|
+
unit: "ms",
|
|
154
|
+
operationId: operation.id,
|
|
155
|
+
},
|
|
156
|
+
...(gap.previous
|
|
157
|
+
? [
|
|
158
|
+
{
|
|
159
|
+
metric: "previousOperation",
|
|
160
|
+
value: gap.previous.name,
|
|
161
|
+
operationId: gap.previous.id,
|
|
162
|
+
},
|
|
163
|
+
]
|
|
164
|
+
: []),
|
|
165
|
+
...(gap.next
|
|
166
|
+
? [{ metric: "nextOperation", value: gap.next.name, operationId: gap.next.id }]
|
|
167
|
+
: []),
|
|
168
|
+
]
|
|
169
|
+
: [];
|
|
170
|
+
return {
|
|
171
|
+
analyzer: { id: "unattributed-v1", version: 1 },
|
|
172
|
+
category: "coverage",
|
|
173
|
+
severity: "warning",
|
|
174
|
+
observation: `${formatMs(operation.timing.wallMs)}${location} could not be attributed to a known child operation.${gapObservation}`,
|
|
175
|
+
evidence: [
|
|
176
|
+
{
|
|
177
|
+
metric: "unattributedWall",
|
|
178
|
+
value: operation.timing.wallMs,
|
|
179
|
+
unit: "ms",
|
|
180
|
+
operationId: operation.id,
|
|
181
|
+
},
|
|
182
|
+
...(parent ? [{ metric: "parentPhase", value: parent.name }] : []),
|
|
183
|
+
...gapEvidence,
|
|
184
|
+
],
|
|
185
|
+
action: unattributedAction(profile, parent, gap),
|
|
186
|
+
confidence: "medium",
|
|
187
|
+
operationIds: [
|
|
188
|
+
operation.id,
|
|
189
|
+
...(gap?.previous ? [gap.previous.id] : []),
|
|
190
|
+
...(gap?.next ? [gap.next.id] : []),
|
|
191
|
+
],
|
|
192
|
+
confidenceReasons: ["Unattributed time is computed from retained child intervals"],
|
|
193
|
+
};
|
|
194
|
+
}),
|
|
195
|
+
};
|
|
196
|
+
function largestUnattributedGap(profile, parent) {
|
|
197
|
+
const parentStart = parent.startOffsetMs;
|
|
198
|
+
const parentEnd = parentStart + parent.timing.wallMs;
|
|
199
|
+
const children = profile.timeline
|
|
200
|
+
.filter(operation => operation.parentId === parent.id &&
|
|
201
|
+
operation.kind !== "testplane.phase.unattributed" &&
|
|
202
|
+
operation.timing.wallMs > 0)
|
|
203
|
+
.map(operation => ({
|
|
204
|
+
operation,
|
|
205
|
+
start: Math.max(parentStart, operation.startOffsetMs),
|
|
206
|
+
end: Math.min(parentEnd, operation.startOffsetMs + operation.timing.wallMs),
|
|
207
|
+
}))
|
|
208
|
+
.filter(interval => interval.end > interval.start)
|
|
209
|
+
.sort((left, right) => left.start - right.start || right.end - left.end);
|
|
210
|
+
if (!children.length) {
|
|
211
|
+
return { wallMs: parent.timing.wallMs };
|
|
212
|
+
}
|
|
213
|
+
const gaps = [];
|
|
214
|
+
let cursor = parentStart;
|
|
215
|
+
let previous;
|
|
216
|
+
for (const child of children) {
|
|
217
|
+
if (child.start > cursor) {
|
|
218
|
+
gaps.push({ wallMs: child.start - cursor, previous, next: child.operation });
|
|
219
|
+
}
|
|
220
|
+
if (child.end > cursor) {
|
|
221
|
+
cursor = child.end;
|
|
222
|
+
previous = child.operation;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (cursor < parentEnd) {
|
|
226
|
+
gaps.push({ wallMs: parentEnd - cursor, previous });
|
|
227
|
+
}
|
|
228
|
+
return gaps.sort((left, right) => right.wallMs - left.wallMs)[0];
|
|
229
|
+
}
|
|
230
|
+
function unattributedGapObservation(gap) {
|
|
231
|
+
if (!gap) {
|
|
232
|
+
return "";
|
|
233
|
+
}
|
|
234
|
+
if (gap.previous && gap.next) {
|
|
235
|
+
return ` The largest unattributed interval was ${formatMs(gap.wallMs)} between ${unattributedBoundary(gap.previous)} and ${unattributedBoundary(gap.next)}.`;
|
|
236
|
+
}
|
|
237
|
+
if (gap.previous) {
|
|
238
|
+
return ` The largest unattributed interval was ${formatMs(gap.wallMs)} after ${unattributedBoundary(gap.previous)}.`;
|
|
239
|
+
}
|
|
240
|
+
if (gap.next) {
|
|
241
|
+
return ` The largest unattributed interval was ${formatMs(gap.wallMs)} before ${unattributedBoundary(gap.next)}.`;
|
|
242
|
+
}
|
|
243
|
+
return "";
|
|
244
|
+
}
|
|
245
|
+
function unattributedBoundary(operation) {
|
|
246
|
+
return `${operationKindLabel(operation.kind)} \`${operation.name}\``;
|
|
247
|
+
}
|
|
248
|
+
const OPERATION_KIND_LABELS = {
|
|
249
|
+
"browser.command": "browser command",
|
|
250
|
+
"event.listener": "event listener",
|
|
251
|
+
"user.callback": "callback",
|
|
252
|
+
"test.hook": "hook",
|
|
253
|
+
"test.body": "test body",
|
|
254
|
+
"test.attempt": "test",
|
|
255
|
+
"worker.test-attempt": "test",
|
|
256
|
+
"module.load": "module load",
|
|
257
|
+
"test.file.load": "test file load",
|
|
258
|
+
"worker.call": "worker call",
|
|
259
|
+
};
|
|
260
|
+
function operationKindLabel(kind) {
|
|
261
|
+
return (OPERATION_KIND_LABELS[kind] ??
|
|
262
|
+
(kind.startsWith("browser.session.")
|
|
263
|
+
? "browser session operation"
|
|
264
|
+
: kind.startsWith("testplane.phase.")
|
|
265
|
+
? "phase"
|
|
266
|
+
: "operation"));
|
|
267
|
+
}
|
|
268
|
+
function unattributedAction(profile, parent, gap) {
|
|
269
|
+
const location = gap?.previous && gap.next
|
|
270
|
+
? `between ${unattributedBoundary(gap.previous)} and ${unattributedBoundary(gap.next)}`
|
|
271
|
+
: gap?.previous
|
|
272
|
+
? `after ${unattributedBoundary(gap.previous)}`
|
|
273
|
+
: gap?.next
|
|
274
|
+
? `before ${unattributedBoundary(gap.next)}`
|
|
275
|
+
: parent
|
|
276
|
+
? `inside \`${parent.name}\``
|
|
277
|
+
: "during this interval";
|
|
278
|
+
const inspect = `Inspect custom code that can run ${location}, including plugins or listeners, callbacks, module initialization, and synchronous filesystem or CPU work. Disable only suspected extensions one at a time.`;
|
|
279
|
+
return profile.level < 3
|
|
280
|
+
? `Try profiler.level 3 to retain more operations around this interval. ${inspect}`
|
|
281
|
+
: inspect;
|
|
282
|
+
}
|
|
283
|
+
const listenerAnalyzer = {
|
|
284
|
+
id: "event-listener-v1",
|
|
285
|
+
minLevel: 2,
|
|
286
|
+
analyze: profile => {
|
|
287
|
+
const aggregates = profile.aggregates.listeners
|
|
288
|
+
.filter(aggregate => !aggregate.name.startsWith("internal:"))
|
|
289
|
+
// A listener is only slow if it is slow per call: a low per-call average across many
|
|
290
|
+
// events is normal, not a bottleneck, even when the cumulative total is large.
|
|
291
|
+
.filter(aggregate => aggregate.meanWallMs >= policy_v1_1.ANALYSIS_POLICY_V1.eventListener.minMeanWallMs &&
|
|
292
|
+
(aggregate.maxWallMs >= policy_v1_1.ANALYSIS_POLICY_V1.eventListener.minSingleWallMs ||
|
|
293
|
+
aggregate.totalWallMs >= policy_v1_1.ANALYSIS_POLICY_V1.eventListener.minTotalWallMs) &&
|
|
294
|
+
aggregate.totalWallMs / profile.durationMs >= policy_v1_1.ANALYSIS_POLICY_V1.eventListener.minRunShare);
|
|
295
|
+
const slowestOperations = aggregates.map(aggregate => retainedOperationsForAggregate(profile, aggregate)[0]);
|
|
296
|
+
const locations = shortestUniqueSourceLocations(slowestOperations.map(operation => operation?.source));
|
|
297
|
+
return aggregates.map((aggregate, index) => listenerFinding(aggregate, profile, slowestOperations[index], locations[index]));
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
const testFileAnalyzer = {
|
|
301
|
+
id: "test-file-v1",
|
|
302
|
+
minLevel: 2,
|
|
303
|
+
analyze: profile => {
|
|
304
|
+
// One file loads many times (per worker/session/retry/browser); collapse to the worst
|
|
305
|
+
// instance per file so a single slow file produces a single finding, not dozens.
|
|
306
|
+
const operations = dedupeByNameMaxWall(profile.timeline.filter(operation => operation.kind.startsWith("test.file")));
|
|
307
|
+
const fileLoadWall = operations.reduce((total, operation) => total + operation.timing.wallMs, 0);
|
|
308
|
+
const candidates = operations.length >= 8
|
|
309
|
+
? (0, outliers_1.findRobustOutliers)(operations, operation => operation.timing.wallMs)
|
|
310
|
+
: operations.map(operation => ({ item: operation, measurement: operation.timing.wallMs }));
|
|
311
|
+
const retained = candidates
|
|
312
|
+
.filter(candidate => candidate.measurement >= policy_v1_1.ANALYSIS_POLICY_V1.testFile.minWallMs)
|
|
313
|
+
.slice(0, Math.max(3, Math.ceil(operations.length * 0.1)));
|
|
314
|
+
if (retained.reduce((total, candidate) => total + candidate.measurement, 0) / Math.max(1, fileLoadWall) <
|
|
315
|
+
policy_v1_1.ANALYSIS_POLICY_V1.testFile.minGroupShare) {
|
|
316
|
+
return [];
|
|
317
|
+
}
|
|
318
|
+
return retained.map(({ item: operation }) => operationFinding("test-file-v1", "test-file", operation, profile, testFileAction(profile, operation)));
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
const moduleDependencyAnalyzer = {
|
|
322
|
+
id: "module-dependency-v1",
|
|
323
|
+
minLevel: 3,
|
|
324
|
+
analyze: profile => {
|
|
325
|
+
const modules = dedupeByNameMaxWall(profile.timeline.filter(operation => operation.kind === "module.load" &&
|
|
326
|
+
typeof operation.attributes.ownerFile === "string" &&
|
|
327
|
+
!operation.attributes.cacheHit &&
|
|
328
|
+
operation.attributes.module !== operation.attributes.ownerFile &&
|
|
329
|
+
// Testplane's own internals are not user-actionable dependencies.
|
|
330
|
+
!isFrameworkModule(String(operation.attributes.module ?? operation.name))));
|
|
331
|
+
const candidates = modules.length >= 8
|
|
332
|
+
? (0, outliers_1.findRobustOutliers)(modules, operation => operation.timing.wallMs)
|
|
333
|
+
: modules.map(operation => ({ item: operation, measurement: operation.timing.wallMs }));
|
|
334
|
+
return candidates
|
|
335
|
+
.filter(({ measurement }) => measurement >= 100 && measurement / profile.durationMs >= 0.01)
|
|
336
|
+
.slice(0, Math.max(3, Math.ceil(modules.length * 0.1)))
|
|
337
|
+
.map(({ item: operation }) => operationFinding("module-dependency-v1", "module", operation, profile, moduleDependencyAction(operation.attributes.ownerFile)));
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
const workerCapacityAnalyzer = {
|
|
341
|
+
id: "worker-capacity-v1",
|
|
342
|
+
minLevel: 1,
|
|
343
|
+
analyze: profile => {
|
|
344
|
+
const capacity = assessWorkerCapacity(profile);
|
|
345
|
+
if (!capacity) {
|
|
346
|
+
return [];
|
|
347
|
+
}
|
|
348
|
+
const pressure = [
|
|
349
|
+
`runTest calls overlapped for ${formatMs(capacity.concurrentCallOverlapMs)}`,
|
|
350
|
+
capacity.eventLoopDelay
|
|
351
|
+
? `worker event-loop delay was ${formatMs(capacity.eventLoopDelay.medianMs)} at p50 and ${formatMs(capacity.eventLoopDelay.ninetyFifthPercentileMs)} at p95`
|
|
352
|
+
: undefined,
|
|
353
|
+
].filter((value) => Boolean(value));
|
|
354
|
+
const hostCpuObservation = capacity.hostCpu95thPercentile === undefined
|
|
355
|
+
? " Host CPU data was unavailable."
|
|
356
|
+
: ` Host CPU was ${formatPercent(capacity.hostCpu95thPercentile)} at p95.`;
|
|
357
|
+
const evidence = [
|
|
358
|
+
{ metric: "currentWorkers", value: capacity.currentWorkers },
|
|
359
|
+
{ metric: "proposedWorkers", value: capacity.proposedWorkers },
|
|
360
|
+
{
|
|
361
|
+
metric: "workerEluP50",
|
|
362
|
+
value: capacity.medianWorkerEventLoopUtilization,
|
|
363
|
+
unit: "ratio",
|
|
364
|
+
threshold: policy_v1_1.ANALYSIS_POLICY_V1.workers.minMedianWorkerEventLoopUtilization,
|
|
365
|
+
},
|
|
366
|
+
{ metric: "activeWorkerCallsPeak", value: capacity.activeCallsPeak },
|
|
367
|
+
{
|
|
368
|
+
metric: "concurrentWorkerCallOverlap",
|
|
369
|
+
value: capacity.concurrentCallOverlapMs,
|
|
370
|
+
unit: "ms",
|
|
371
|
+
threshold: policy_v1_1.ANALYSIS_POLICY_V1.workers.minConcurrentCallOverlapMs,
|
|
372
|
+
},
|
|
373
|
+
];
|
|
374
|
+
if (capacity.hostCpu95thPercentile !== undefined) {
|
|
375
|
+
evidence.push({
|
|
376
|
+
metric: "hostCpuP95",
|
|
377
|
+
value: capacity.hostCpu95thPercentile,
|
|
378
|
+
unit: "ratio",
|
|
379
|
+
threshold: policy_v1_1.ANALYSIS_POLICY_V1.workers.maxHostCpu95thPercentile,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
if (capacity.eventLoopDelay) {
|
|
383
|
+
evidence.push({
|
|
384
|
+
metric: "eventLoopDelayP50",
|
|
385
|
+
value: capacity.eventLoopDelay.medianMs,
|
|
386
|
+
unit: "ms",
|
|
387
|
+
threshold: policy_v1_1.ANALYSIS_POLICY_V1.workers.minSustainedEventLoopDelayMs,
|
|
388
|
+
}, {
|
|
389
|
+
metric: "eventLoopDelayP95",
|
|
390
|
+
value: capacity.eventLoopDelay.ninetyFifthPercentileMs,
|
|
391
|
+
unit: "ms",
|
|
392
|
+
}, { metric: "eventLoopDelaySamples", value: capacity.eventLoopDelay.samples });
|
|
393
|
+
}
|
|
394
|
+
return [
|
|
395
|
+
simpleFinding({
|
|
396
|
+
analyzerId: "worker-capacity-v1",
|
|
397
|
+
category: "workers",
|
|
398
|
+
observation: `Workers were CPU-saturated (${formatPercent(capacity.medianWorkerEventLoopUtilization)} event-loop utilization at p50) while ${pressure.join(" and ")}.${hostCpuObservation}`,
|
|
399
|
+
evidence,
|
|
400
|
+
action: workerCapacityAction(capacity),
|
|
401
|
+
confidence: "medium",
|
|
402
|
+
operationIds: [],
|
|
403
|
+
confidenceReasons: ["The suggested gain is a counterfactual estimate"],
|
|
404
|
+
}),
|
|
405
|
+
];
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
function assessWorkerCapacity(profile) {
|
|
409
|
+
const currentWorkers = metricValue(profile, "config.workers");
|
|
410
|
+
const availableParallelism = metricValue(profile, "config.availableParallelism");
|
|
411
|
+
const medianWorkerEventLoopUtilization = sampledAggregateValue(profile, "process.eventLoopUtilization", "p50WallMs", { process: "worker" });
|
|
412
|
+
if (profile.durationMs < policy_v1_1.ANALYSIS_POLICY_V1.workers.minExecutionMs ||
|
|
413
|
+
currentWorkers === undefined ||
|
|
414
|
+
availableParallelism === undefined ||
|
|
415
|
+
currentWorkers >= availableParallelism ||
|
|
416
|
+
medianWorkerEventLoopUtilization === undefined ||
|
|
417
|
+
medianWorkerEventLoopUtilization < policy_v1_1.ANALYSIS_POLICY_V1.workers.minMedianWorkerEventLoopUtilization) {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const activeCallsPeak = metricValue(profile, "worker.activeCalls.peak") ?? 0;
|
|
421
|
+
const concurrentCallOverlapMs = durationAtConcurrency(profile.timeline.filter(operation => operation.kind === "worker.call" && operation.name === "runTest"), 2);
|
|
422
|
+
const hasDistributableCalls = activeCallsPeak > currentWorkers &&
|
|
423
|
+
concurrentCallOverlapMs >= policy_v1_1.ANALYSIS_POLICY_V1.workers.minConcurrentCallOverlapMs &&
|
|
424
|
+
concurrentCallOverlapMs / profile.durationMs >= policy_v1_1.ANALYSIS_POLICY_V1.workers.minConcurrentCallOverlapShare;
|
|
425
|
+
const eventLoopDelay = sustainedEventLoopDelay(findAggregate(profile, "process.eventLoopDelayP95Ms", { process: "worker" }));
|
|
426
|
+
const hostCpu95thPercentile = sampledAggregateValue(profile, "host.cpuUtilization", "p95WallMs", {
|
|
427
|
+
process: "host",
|
|
428
|
+
});
|
|
429
|
+
const hasHostHeadroom = hostCpu95thPercentile !== undefined &&
|
|
430
|
+
hostCpu95thPercentile < policy_v1_1.ANALYSIS_POLICY_V1.workers.maxHostCpu95thPercentile;
|
|
431
|
+
if (!hasDistributableCalls || (!hasHostHeadroom && !eventLoopDelay)) {
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const proposedWorkers = Math.min(availableParallelism, activeCallsPeak, Math.ceil(currentWorkers * 1.25));
|
|
435
|
+
return {
|
|
436
|
+
currentWorkers,
|
|
437
|
+
proposedWorkers,
|
|
438
|
+
activeCallsPeak,
|
|
439
|
+
concurrentCallOverlapMs,
|
|
440
|
+
medianWorkerEventLoopUtilization,
|
|
441
|
+
hostCpu95thPercentile,
|
|
442
|
+
eventLoopDelay,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
function sustainedEventLoopDelay(aggregate) {
|
|
446
|
+
if ((aggregate?.count ?? 0) < policy_v1_1.ANALYSIS_POLICY_V1.resources.minSamples ||
|
|
447
|
+
aggregate?.p50WallMs === undefined ||
|
|
448
|
+
aggregate.p50WallMs < policy_v1_1.ANALYSIS_POLICY_V1.workers.minSustainedEventLoopDelayMs ||
|
|
449
|
+
aggregate.p95WallMs === undefined) {
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
return {
|
|
453
|
+
medianMs: aggregate.p50WallMs,
|
|
454
|
+
ninetyFifthPercentileMs: aggregate.p95WallMs,
|
|
455
|
+
samples: aggregate.count,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
function workerCapacityAction(capacity) {
|
|
459
|
+
const action = `Try system.workers: ${capacity.proposedWorkers} (currently ${capacity.currentWorkers}) and compare worker event-loop delay and total run time on the next run.`;
|
|
460
|
+
return capacity.hostCpu95thPercentile !== undefined &&
|
|
461
|
+
capacity.hostCpu95thPercentile >= policy_v1_1.ANALYSIS_POLICY_V1.workers.maxHostCpu95thPercentile
|
|
462
|
+
? `${action} Host CPU was already high, so revert the increase if it remains saturated; then add CPU capacity or split the run across separate CI hosts with @testplane/chunks.`
|
|
463
|
+
: action;
|
|
464
|
+
}
|
|
465
|
+
const sessionConcurrencyAnalyzer = {
|
|
466
|
+
id: "session-concurrency-v1",
|
|
467
|
+
minLevel: 2,
|
|
468
|
+
analyze: profile => {
|
|
469
|
+
const attemptsByBrowser = groupOperations(profile.timeline.filter(operation => operation.kind === "test.attempt" && operation.context.browserId), operation => operation.context.browserId);
|
|
470
|
+
const browsers = [...attemptsByBrowser]
|
|
471
|
+
.map(([browserId, attempts]) => ({
|
|
472
|
+
browserId,
|
|
473
|
+
attempts,
|
|
474
|
+
end: Math.max(...attempts.map(operation => operation.startOffsetMs + operation.timing.wallMs)),
|
|
475
|
+
}))
|
|
476
|
+
.sort((left, right) => right.end - left.end);
|
|
477
|
+
if (browsers.length < 2) {
|
|
478
|
+
return [];
|
|
479
|
+
}
|
|
480
|
+
const [browser, nextBrowser] = browsers;
|
|
481
|
+
if (nextBrowser.end <= 0) {
|
|
482
|
+
return [];
|
|
483
|
+
}
|
|
484
|
+
const hostCpu95thPercentile = sampledAggregateValue(profile, "host.cpuUtilization", "p95WallMs", {
|
|
485
|
+
process: "host",
|
|
486
|
+
});
|
|
487
|
+
const medianWorkerEventLoopUtilization = sampledAggregateValue(profile, "process.eventLoopUtilization", "p50WallMs", { process: "worker" });
|
|
488
|
+
const medianWorker95thPercentileEventLoopDelayMs = sampledAggregateValue(profile, "process.eventLoopDelayP95Ms", "p50WallMs", { process: "worker" });
|
|
489
|
+
const globalQueuePeak = maxMetricValue(profile, "browser.pool.queueDepth.peak", { limiter: "global" });
|
|
490
|
+
const minimumBrowserTailMs = Math.max(policy_v1_1.ANALYSIS_POLICY_V1.sessions.minTailMs, profile.durationMs * policy_v1_1.ANALYSIS_POLICY_V1.sessions.minTailShare);
|
|
491
|
+
const currentSessionsPerBrowser = metricValue(profile, "config.sessionsPerBrowser", {
|
|
492
|
+
browserId: browser.browserId,
|
|
493
|
+
});
|
|
494
|
+
const browserQueuePeak = metricValue(profile, "browser.pool.queueDepth.peak", {
|
|
495
|
+
browserId: browser.browserId,
|
|
496
|
+
limiter: "browser",
|
|
497
|
+
}) ?? 0;
|
|
498
|
+
const sessionSampleCount = metricValue(profile, "browser.pool.sessionsLaunched.sampleCount", {
|
|
499
|
+
browserId: browser.browserId,
|
|
500
|
+
limiter: "browser",
|
|
501
|
+
}) ?? 0;
|
|
502
|
+
const saturatedSessionSampleCount = metricValue(profile, "browser.pool.sessionsLaunched.saturatedSampleCount", {
|
|
503
|
+
browserId: browser.browserId,
|
|
504
|
+
limiter: "browser",
|
|
505
|
+
}) ?? 0;
|
|
506
|
+
const sessionLimitSaturationShare = sessionSampleCount ? saturatedSessionSampleCount / sessionSampleCount : 0;
|
|
507
|
+
const browserTailMs = browser.end - nextBrowser.end;
|
|
508
|
+
if (currentSessionsPerBrowser === undefined ||
|
|
509
|
+
browser.attempts.length < policy_v1_1.ANALYSIS_POLICY_V1.sessions.minAttempts ||
|
|
510
|
+
browserQueuePeak <= 0 ||
|
|
511
|
+
sessionLimitSaturationShare < policy_v1_1.ANALYSIS_POLICY_V1.sessions.minLimitSaturationShare ||
|
|
512
|
+
hostCpu95thPercentile === undefined ||
|
|
513
|
+
hostCpu95thPercentile >= policy_v1_1.ANALYSIS_POLICY_V1.workers.maxHostCpu95thPercentile ||
|
|
514
|
+
(medianWorkerEventLoopUtilization ?? 0) >= policy_v1_1.ANALYSIS_POLICY_V1.workers.minMedianWorkerEventLoopUtilization ||
|
|
515
|
+
(medianWorker95thPercentileEventLoopDelayMs ?? 0) >=
|
|
516
|
+
policy_v1_1.ANALYSIS_POLICY_V1.workers.minSustainedEventLoopDelayMs ||
|
|
517
|
+
(globalQueuePeak ?? 0) !== 0 ||
|
|
518
|
+
browserTailMs < minimumBrowserTailMs) {
|
|
519
|
+
return [];
|
|
520
|
+
}
|
|
521
|
+
const proposedSessionsPerBrowser = Math.ceil((browser.end / nextBrowser.end) * currentSessionsPerBrowser);
|
|
522
|
+
return [
|
|
523
|
+
simpleFinding({
|
|
524
|
+
analyzerId: "session-concurrency-v1",
|
|
525
|
+
category: "sessions",
|
|
526
|
+
observation: `\`${browser.browserId}\` tests took: ${formatMs(browser.end)}; \`${nextBrowser.browserId}\` tests took: ${formatMs(nextBrowser.end)}. \`${browser.browserId}\` finished ${formatMs(browserTailMs)} later.`,
|
|
527
|
+
evidence: [
|
|
528
|
+
{ metric: "browser", value: browser.browserId },
|
|
529
|
+
{ metric: "browserQueuePeak", value: browserQueuePeak },
|
|
530
|
+
{ metric: "attempts", value: browser.attempts.length },
|
|
531
|
+
{ metric: "browserEnd", value: browser.end, unit: "ms" },
|
|
532
|
+
{ metric: "nextBrowser", value: nextBrowser.browserId },
|
|
533
|
+
{ metric: "nextBrowserEnd", value: nextBrowser.end, unit: "ms" },
|
|
534
|
+
{
|
|
535
|
+
metric: "browserTail",
|
|
536
|
+
value: browserTailMs,
|
|
537
|
+
unit: "ms",
|
|
538
|
+
threshold: minimumBrowserTailMs,
|
|
539
|
+
},
|
|
540
|
+
{ metric: "currentSessionsPerBrowser", value: currentSessionsPerBrowser },
|
|
541
|
+
{ metric: "proposedSessionsPerBrowser", value: proposedSessionsPerBrowser },
|
|
542
|
+
{
|
|
543
|
+
metric: "sessionLimitSaturationShare",
|
|
544
|
+
value: sessionLimitSaturationShare,
|
|
545
|
+
unit: "ratio",
|
|
546
|
+
threshold: policy_v1_1.ANALYSIS_POLICY_V1.sessions.minLimitSaturationShare,
|
|
547
|
+
},
|
|
548
|
+
],
|
|
549
|
+
action: `Try sessionsPerBrowser: ${proposedSessionsPerBrowser} for ${browser.browserId} (currently ${currentSessionsPerBrowser}), unless an external grid limit is already saturated.`,
|
|
550
|
+
confidence: "medium",
|
|
551
|
+
operationIds: browser.attempts.map(operation => operation.id).slice(0, 20),
|
|
552
|
+
confidenceReasons: [
|
|
553
|
+
"The recommendation predicts the next run and cannot observe external grid capacity",
|
|
554
|
+
"The target assumes browser time scales inversely with the number of sessions",
|
|
555
|
+
],
|
|
556
|
+
}),
|
|
557
|
+
];
|
|
558
|
+
},
|
|
559
|
+
};
|
|
560
|
+
const resourceSaturationAnalyzer = {
|
|
561
|
+
id: "resource-saturation-v1",
|
|
562
|
+
minLevel: 1,
|
|
563
|
+
analyze: profile => {
|
|
564
|
+
const hostCpu95thPercentile = sampledAggregateValue(profile, "host.cpuUtilization", "p95WallMs", {
|
|
565
|
+
process: "host",
|
|
566
|
+
});
|
|
567
|
+
const medianWorkerEventLoopUtilization = sampledAggregateValue(profile, "process.eventLoopUtilization", "p50WallMs", { process: "worker" });
|
|
568
|
+
const highestMedianEventLoopDelayAggregate = maxAggregate(profile, "process.eventLoopDelayP95Ms", "p50WallMs", policy_v1_1.ANALYSIS_POLICY_V1.resources.minSamples);
|
|
569
|
+
const eventLoopDelayStats = sustainedEventLoopDelay(highestMedianEventLoopDelayAggregate);
|
|
570
|
+
const affectedProcess = String(highestMedianEventLoopDelayAggregate?.attributes?.process ?? "Node");
|
|
571
|
+
const workerCapacity = assessWorkerCapacity(profile);
|
|
572
|
+
const findings = [];
|
|
573
|
+
if (hostCpu95thPercentile !== undefined &&
|
|
574
|
+
hostCpu95thPercentile >= policy_v1_1.ANALYSIS_POLICY_V1.hostCpu.high95thPercentile) {
|
|
575
|
+
findings.push(simpleFinding({
|
|
576
|
+
analyzerId: "host-cpu-v1",
|
|
577
|
+
category: "cpu",
|
|
578
|
+
observation: `Host CPU utilization reached ${formatPercent(hostCpu95thPercentile)} at p95.${medianWorkerEventLoopUtilization === undefined
|
|
579
|
+
? ""
|
|
580
|
+
: ` Worker event-loop utilization was ${formatPercent(medianWorkerEventLoopUtilization)} at p50.`}`,
|
|
581
|
+
evidence: [
|
|
582
|
+
{
|
|
583
|
+
metric: "hostCpuP95",
|
|
584
|
+
value: hostCpu95thPercentile,
|
|
585
|
+
unit: "ratio",
|
|
586
|
+
threshold: policy_v1_1.ANALYSIS_POLICY_V1.hostCpu.high95thPercentile,
|
|
587
|
+
},
|
|
588
|
+
...(medianWorkerEventLoopUtilization === undefined
|
|
589
|
+
? []
|
|
590
|
+
: [
|
|
591
|
+
{
|
|
592
|
+
metric: "workerEluP50",
|
|
593
|
+
value: medianWorkerEventLoopUtilization,
|
|
594
|
+
unit: "ratio",
|
|
595
|
+
threshold: policy_v1_1.ANALYSIS_POLICY_V1.workers.minMedianWorkerEventLoopUtilization,
|
|
596
|
+
},
|
|
597
|
+
]),
|
|
598
|
+
],
|
|
599
|
+
action: hostCpuAction(medianWorkerEventLoopUtilization, workerCapacity),
|
|
600
|
+
confidence: "medium",
|
|
601
|
+
operationIds: [],
|
|
602
|
+
confidenceReasons: ["Host CPU is sampled and may include unrelated processes"],
|
|
603
|
+
}));
|
|
604
|
+
}
|
|
605
|
+
if (eventLoopDelayStats && !(affectedProcess === "worker" && workerCapacity?.eventLoopDelay)) {
|
|
606
|
+
const processLabel = affectedProcess === "worker" ? "Worker processes" : `${capitalize(affectedProcess)} process`;
|
|
607
|
+
findings.push(simpleFinding({
|
|
608
|
+
analyzerId: "event-loop-delay-v1",
|
|
609
|
+
category: "event-loop",
|
|
610
|
+
observation: `${processLabel} event-loop delay was ${formatMs(eventLoopDelayStats.medianMs)} at p50 and reached ${formatMs(eventLoopDelayStats.ninetyFifthPercentileMs)} at p95 across ${eventLoopDelayStats.samples} samples. Healthy event-loop delay is below ${formatMs(policy_v1_1.ANALYSIS_POLICY_V1.resources.healthyMedianEventLoopDelayMs)} at p50.`,
|
|
611
|
+
evidence: [
|
|
612
|
+
{
|
|
613
|
+
metric: "eventLoopDelayP50",
|
|
614
|
+
value: eventLoopDelayStats.medianMs,
|
|
615
|
+
unit: "ms",
|
|
616
|
+
threshold: policy_v1_1.ANALYSIS_POLICY_V1.workers.minSustainedEventLoopDelayMs,
|
|
617
|
+
},
|
|
618
|
+
{
|
|
619
|
+
metric: "eventLoopDelayP95",
|
|
620
|
+
value: eventLoopDelayStats.ninetyFifthPercentileMs,
|
|
621
|
+
unit: "ms",
|
|
622
|
+
},
|
|
623
|
+
{ metric: "eventLoopDelaySamples", value: eventLoopDelayStats.samples },
|
|
624
|
+
{ metric: "process", value: affectedProcess },
|
|
625
|
+
],
|
|
626
|
+
action: eventLoopDelayAction(affectedProcess, processLabel),
|
|
627
|
+
confidence: "medium",
|
|
628
|
+
operationIds: [],
|
|
629
|
+
confidenceReasons: ["Event-loop delay is a process-window metric, not exclusive function CPU"],
|
|
630
|
+
}));
|
|
631
|
+
}
|
|
632
|
+
return findings;
|
|
633
|
+
},
|
|
634
|
+
};
|
|
635
|
+
function eventLoopDelayAction(process, processLabel) {
|
|
636
|
+
const healthyMedianDelay = formatMs(policy_v1_1.ANALYSIS_POLICY_V1.resources.healthyMedianEventLoopDelayMs);
|
|
637
|
+
const reduceDelay = `Reduce p50 below ${healthyMedianDelay} by removing long synchronous sections: replace synchronous filesystem calls with async equivalents, and optimize or move CPU-heavy parsing and calculations off the affected Node event loop.`;
|
|
638
|
+
if (process === "worker") {
|
|
639
|
+
return `${reduceDelay} The profile does not prove that more system.workers would help. Start with the concrete slow listener, hook, dependency, test, or command findings printed in this report. If none explains the delay, try profiler.level 3 and inspect worker-side plugins and test files.`;
|
|
640
|
+
}
|
|
641
|
+
return `${reduceDelay} Start with the slow phase, listener, and file findings printed for ${processLabel.toLowerCase()}. If none explains the delay, try profiler.level 3 and inspect custom code in that process.`;
|
|
642
|
+
}
|
|
643
|
+
function hostCpuAction(medianWorkerEventLoopUtilization, workerCapacity) {
|
|
644
|
+
if (medianWorkerEventLoopUtilization === undefined) {
|
|
645
|
+
return "Host CPU includes Testplane and unrelated processes. Check top/htop and CI or container CPU limits to identify the saturated process before changing system.workers.";
|
|
646
|
+
}
|
|
647
|
+
if (medianWorkerEventLoopUtilization >= policy_v1_1.ANALYSIS_POLICY_V1.workers.minMedianWorkerEventLoopUtilization) {
|
|
648
|
+
if (workerCapacity) {
|
|
649
|
+
return `Testplane workers were also busy and parallel work exceeded system.workers. Follow the Worker capacity recommendation up to ${workerCapacity.proposedWorkers} workers, then recheck host CPU. If the host stays saturated, reduce CPU-heavy test or plugin work, add CPU capacity, or split the run across separate CI hosts with @testplane/chunks.`;
|
|
650
|
+
}
|
|
651
|
+
return "Testplane workers were also busy. Do not add more workers on the same CPU-saturated host. Reduce CPU-heavy test or plugin work, add CPU capacity, or split the run across separate CI hosts with @testplane/chunks.";
|
|
652
|
+
}
|
|
653
|
+
return "Testplane workers were not saturated, so the load likely comes from another process or a CI/container CPU limit. Check top/htop and the CPU limit, then free or add capacity before changing system.workers.";
|
|
654
|
+
}
|
|
655
|
+
const slowTestAnalyzer = {
|
|
656
|
+
id: "slow-test-v1",
|
|
657
|
+
minLevel: 2,
|
|
658
|
+
analyze: profile => {
|
|
659
|
+
const tests = profile.aggregates.tests.filter(aggregate => aggregate.kind === "test.body");
|
|
660
|
+
if (!tests.length) {
|
|
661
|
+
return [];
|
|
662
|
+
}
|
|
663
|
+
const meanPerCall = tests.reduce((sum, aggregate) => sum + aggregate.meanWallMs, 0) / tests.length;
|
|
664
|
+
const relativeGate = tests.length >= policy_v1_1.ANALYSIS_POLICY_V1.slowTest.minSampleForRelative
|
|
665
|
+
? meanPerCall * policy_v1_1.ANALYSIS_POLICY_V1.slowTest.minMeanMultiple
|
|
666
|
+
: 0;
|
|
667
|
+
return tests
|
|
668
|
+
.filter(aggregate => aggregate.maxWallMs >= policy_v1_1.ANALYSIS_POLICY_V1.slowTest.minWallMs &&
|
|
669
|
+
aggregate.totalWallMs / profile.durationMs >= policy_v1_1.ANALYSIS_POLICY_V1.slowTest.minExecutionShare &&
|
|
670
|
+
aggregate.meanWallMs >= relativeGate)
|
|
671
|
+
.map(aggregate => aggregateFinding("slow-test-v1", "test", aggregate, profile, "Inspect slow commands and awaited operations inside this test body; remove avoidable waits from its critical path."));
|
|
672
|
+
},
|
|
673
|
+
};
|
|
674
|
+
const hookAnalyzer = {
|
|
675
|
+
id: "common-hook-v1",
|
|
676
|
+
minLevel: 2,
|
|
677
|
+
analyze: profile => {
|
|
678
|
+
const findings = [];
|
|
679
|
+
for (const aggregate of profile.aggregates.hooks) {
|
|
680
|
+
if (aggregate.count < policy_v1_1.ANALYSIS_POLICY_V1.commonHook.minCalls ||
|
|
681
|
+
aggregate.totalWallMs < policy_v1_1.ANALYSIS_POLICY_V1.commonHook.minTotalWallMs ||
|
|
682
|
+
aggregate.totalWallMs / profile.durationMs < policy_v1_1.ANALYSIS_POLICY_V1.commonHook.minExecutionShare) {
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
// Parallel tests inflate cumulative hook work. For moderate hooks, require their
|
|
686
|
+
// retained intervals to occupy a material part of the run's actual wall time.
|
|
687
|
+
const observedWallMs = operationUnionWall(profile, aggregate);
|
|
688
|
+
if (aggregate.meanWallMs < policy_v1_1.ANALYSIS_POLICY_V1.commonHook.minMeanWallMs &&
|
|
689
|
+
observedWallMs / profile.durationMs < policy_v1_1.ANALYSIS_POLICY_V1.commonHook.minExecutionShare) {
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
const finding = aggregateFinding("common-hook-v1", "hook", aggregate, profile, "Check whether every test in this suite needs the hook's full setup. Optimize the repeated work, or split the tests into smaller suites with focused hooks so each test runs only the setup it needs.");
|
|
693
|
+
if (observedWallMs > 0) {
|
|
694
|
+
finding.evidence.push({
|
|
695
|
+
metric: "observedUnionWall",
|
|
696
|
+
value: observedWallMs,
|
|
697
|
+
unit: "ms",
|
|
698
|
+
threshold: profile.durationMs * policy_v1_1.ANALYSIS_POLICY_V1.commonHook.minExecutionShare,
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
findings.push(finding);
|
|
702
|
+
}
|
|
703
|
+
return findings;
|
|
704
|
+
},
|
|
705
|
+
};
|
|
706
|
+
const commandAnalyzer = {
|
|
707
|
+
id: "browser-command-v1",
|
|
708
|
+
minLevel: 3,
|
|
709
|
+
analyze: profile => profile.aggregates.commands
|
|
710
|
+
.filter(aggregate => aggregate.name !== "<root>")
|
|
711
|
+
.flatMap(aggregate => {
|
|
712
|
+
if (aggregate.name === "pause") {
|
|
713
|
+
const matchesPause = aggregate.totalWallMs >= policy_v1_1.ANALYSIS_POLICY_V1.pause.minTotalWallMs &&
|
|
714
|
+
(aggregate.count >= policy_v1_1.ANALYSIS_POLICY_V1.pause.minCalls ||
|
|
715
|
+
aggregate.maxWallMs >= policy_v1_1.ANALYSIS_POLICY_V1.pause.minSingleWallMs) &&
|
|
716
|
+
aggregate.totalWallMs / profile.durationMs >= policy_v1_1.ANALYSIS_POLICY_V1.pause.minShare;
|
|
717
|
+
return matchesPause
|
|
718
|
+
? [
|
|
719
|
+
aggregateFinding("browser-pause-v1", "browser-command", aggregate, profile, "Replace static browser.pause calls with a condition-based wait."),
|
|
720
|
+
]
|
|
721
|
+
: [];
|
|
722
|
+
}
|
|
723
|
+
return aggregate.meanWallMs >= policy_v1_1.ANALYSIS_POLICY_V1.command.minMeanWallMs &&
|
|
724
|
+
aggregate.totalWallMs >= policy_v1_1.ANALYSIS_POLICY_V1.command.minWallMs &&
|
|
725
|
+
aggregate.totalWallMs / profile.durationMs >= policy_v1_1.ANALYSIS_POLICY_V1.command.minExecutionShare
|
|
726
|
+
? [commandFinding(aggregate, profile)]
|
|
727
|
+
: [];
|
|
728
|
+
}),
|
|
729
|
+
};
|
|
730
|
+
const aggregateFinding = (analyzerId, category, aggregate, profile, action) => {
|
|
731
|
+
const retained = retainedOperationsForAggregate(profile, aggregate);
|
|
732
|
+
return {
|
|
733
|
+
analyzer: { id: analyzerId, version: 1 },
|
|
734
|
+
category,
|
|
735
|
+
severity: "warning",
|
|
736
|
+
observation: `\`${aggregate.name}\` used ${formatMs(aggregate.totalWallMs)} across ${aggregate.count} call(s).`,
|
|
737
|
+
evidence: [
|
|
738
|
+
{ metric: "count", value: aggregate.count },
|
|
739
|
+
{ metric: "totalWall", value: aggregate.totalWallMs, unit: "ms" },
|
|
740
|
+
{ metric: "meanWall", value: aggregate.meanWallMs, unit: "ms" },
|
|
741
|
+
{ metric: "maxWall", value: aggregate.maxWallMs, unit: "ms" },
|
|
742
|
+
{ metric: "runShare", value: aggregate.totalWallMs / profile.durationMs, unit: "ratio" },
|
|
743
|
+
...retained.slice(0, 1).map(operation => ({
|
|
744
|
+
metric: "slowestRetainedCall",
|
|
745
|
+
value: operation.timing.wallMs,
|
|
746
|
+
unit: "ms",
|
|
747
|
+
operationId: operation.id,
|
|
748
|
+
})),
|
|
749
|
+
],
|
|
750
|
+
action,
|
|
751
|
+
confidence: "medium",
|
|
752
|
+
operationIds: retained.map(operation => operation.id),
|
|
753
|
+
entityIds: [aggregate.name],
|
|
754
|
+
confidenceReasons: ["The total is cumulative work and may overlap other operations"],
|
|
755
|
+
};
|
|
756
|
+
};
|
|
757
|
+
const operationFinding = (analyzerId, category, operation, profile, action) => ({
|
|
758
|
+
analyzer: { id: analyzerId, version: 1 },
|
|
759
|
+
category,
|
|
760
|
+
severity: "warning",
|
|
761
|
+
observation: `\`${operation.name}\` took ${formatMs(operation.timing.wallMs)}.`,
|
|
762
|
+
evidence: [
|
|
763
|
+
{ metric: "wall", value: operation.timing.wallMs, unit: "ms", operationId: operation.id },
|
|
764
|
+
{
|
|
765
|
+
metric: "runShare",
|
|
766
|
+
value: operation.timing.wallMs / profile.durationMs,
|
|
767
|
+
unit: "ratio",
|
|
768
|
+
operationId: operation.id,
|
|
769
|
+
},
|
|
770
|
+
],
|
|
771
|
+
action,
|
|
772
|
+
confidence: operation.quality.timing === "exact" ? "high" : "medium",
|
|
773
|
+
operationIds: [operation.id],
|
|
774
|
+
entityIds: [operation.name],
|
|
775
|
+
});
|
|
776
|
+
const simpleFinding = ({ analyzerId, ...finding }) => ({
|
|
777
|
+
analyzer: { id: analyzerId, version: 1 },
|
|
778
|
+
severity: "warning",
|
|
779
|
+
...finding,
|
|
780
|
+
});
|
|
781
|
+
function testDiscoveryFinding(profile, phase) {
|
|
782
|
+
const breakdown = testDiscoveryBreakdown(profile, phase);
|
|
783
|
+
const evidence = [
|
|
784
|
+
{
|
|
785
|
+
metric: "wall",
|
|
786
|
+
value: phase.timing.wallMs,
|
|
787
|
+
unit: "ms",
|
|
788
|
+
threshold: policy_v1_1.ANALYSIS_POLICY_V1.majorPhase.minWallMs,
|
|
789
|
+
operationId: phase.id,
|
|
790
|
+
},
|
|
791
|
+
{
|
|
792
|
+
metric: "runShare",
|
|
793
|
+
value: phase.timing.wallMs / profile.durationMs,
|
|
794
|
+
unit: "ratio",
|
|
795
|
+
threshold: policy_v1_1.ANALYSIS_POLICY_V1.majorPhase.minRunShare,
|
|
796
|
+
operationId: phase.id,
|
|
797
|
+
},
|
|
798
|
+
{ metric: "discoveryGlobWall", value: breakdown.globWallMs, unit: "ms" },
|
|
799
|
+
{ metric: "fileLoadWall", value: breakdown.fileLoadWallMs, unit: "ms" },
|
|
800
|
+
{ metric: "testParseWall", value: breakdown.parseWallMs, unit: "ms" },
|
|
801
|
+
{ metric: "otherDiscoveryWall", value: breakdown.otherWallMs, unit: "ms" },
|
|
802
|
+
];
|
|
803
|
+
if (breakdown.fileCount !== undefined) {
|
|
804
|
+
evidence.push({ metric: "fileCount", value: breakdown.fileCount });
|
|
805
|
+
}
|
|
806
|
+
if (breakdown.averageFileLoadMs !== undefined) {
|
|
807
|
+
evidence.push({ metric: "averageFileLoad", value: breakdown.averageFileLoadMs, unit: "ms" }, { metric: "fileLoadOutlierThreshold", value: breakdown.averageFileLoadMs * 3, unit: "ms" });
|
|
808
|
+
}
|
|
809
|
+
if (breakdown.hasFileLoadOutliers !== undefined) {
|
|
810
|
+
evidence.push({ metric: "hasFileLoadOutliers", value: breakdown.hasFileLoadOutliers });
|
|
811
|
+
}
|
|
812
|
+
for (const outlier of breakdown.outliers) {
|
|
813
|
+
evidence.push({ metric: "fileLoadOutlier", value: outlier.name, operationId: outlier.id }, { metric: "fileLoadOutlierWall", value: outlier.timing.wallMs, unit: "ms", operationId: outlier.id });
|
|
814
|
+
}
|
|
815
|
+
const discoveryOnly = profile.operation === "readTests" || profile.operation === "cli:list-tests";
|
|
816
|
+
return {
|
|
817
|
+
analyzer: { id: "test-discovery-v1", version: 1 },
|
|
818
|
+
category: "test-discovery",
|
|
819
|
+
severity: "warning",
|
|
820
|
+
observation: `\`${phase.name}\` took ${formatMs(phase.timing.wallMs)} (${formatPercent(phase.timing.wallMs / profile.durationMs)} of the run).${discoveryOnly ? " This operation does not execute tests." : ""}`,
|
|
821
|
+
evidence,
|
|
822
|
+
action: testDiscoveryAction(profile, breakdown),
|
|
823
|
+
confidence: "high",
|
|
824
|
+
operationIds: [...new Set([phase.id, ...breakdown.operations.map(operation => operation.id)])].slice(0, 12),
|
|
825
|
+
entityIds: [phase.name],
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
function testDiscoveryBreakdown(profile, phase) {
|
|
829
|
+
const children = profile.timeline.filter(operation => operation.parentId === phase.id &&
|
|
830
|
+
operation.process.type === "master" &&
|
|
831
|
+
operation.kind !== "testplane.phase.unattributed");
|
|
832
|
+
const globOperations = children.filter(operation => operation.kind === "sets.resolve-and-glob");
|
|
833
|
+
const fileLoadOperation = children
|
|
834
|
+
.filter(operation => operation.kind === "files.load")
|
|
835
|
+
.sort((left, right) => right.timing.wallMs - left.timing.wallMs)[0];
|
|
836
|
+
const parseOperations = children.filter(operation => ["files.group-by-browser", "tests.parse", "tests.validate"].includes(operation.kind));
|
|
837
|
+
const knownOperations = [...globOperations, ...(fileLoadOperation ? [fileLoadOperation] : []), ...parseOperations];
|
|
838
|
+
const knownWallMs = (0, aggregator_1.operationUnionWithin)(phase, knownOperations);
|
|
839
|
+
const fileSummary = profile.aggregates.testFiles.find(aggregate => aggregate.kind === "test.file.load.summary" &&
|
|
840
|
+
(aggregate.attributes?.process === "master" || aggregate.name === "master"));
|
|
841
|
+
const fileCount = typeof fileLoadOperation?.attributes.files === "number"
|
|
842
|
+
? fileLoadOperation.attributes.files
|
|
843
|
+
: fileSummary?.count;
|
|
844
|
+
const averageFileLoadMs = fileSummary?.meanWallMs ??
|
|
845
|
+
(fileLoadOperation && fileCount ? fileLoadOperation.timing.wallMs / fileCount : undefined);
|
|
846
|
+
const outlierThresholdMs = averageFileLoadMs !== undefined && averageFileLoadMs > 0 ? averageFileLoadMs * 3 : undefined;
|
|
847
|
+
const retainedFileLoads = fileLoadOperation
|
|
848
|
+
? profile.timeline
|
|
849
|
+
.filter(operation => operation.parentId === fileLoadOperation.id &&
|
|
850
|
+
operation.kind === "test.file.load" &&
|
|
851
|
+
operation.process.type === "master")
|
|
852
|
+
.sort((left, right) => right.timing.wallMs - left.timing.wallMs)
|
|
853
|
+
: [];
|
|
854
|
+
const outliers = outlierThresholdMs === undefined
|
|
855
|
+
? []
|
|
856
|
+
: retainedFileLoads.filter(operation => operation.timing.wallMs >= outlierThresholdMs).slice(0, 3);
|
|
857
|
+
const hasFileLoadOutliers = outlierThresholdMs === undefined
|
|
858
|
+
? undefined
|
|
859
|
+
: fileSummary
|
|
860
|
+
? fileSummary.maxWallMs >= outlierThresholdMs
|
|
861
|
+
: profile.level >= 2
|
|
862
|
+
? outliers.length > 0
|
|
863
|
+
: undefined;
|
|
864
|
+
return {
|
|
865
|
+
globWallMs: (0, aggregator_1.operationUnionWithin)(phase, globOperations),
|
|
866
|
+
fileLoadWallMs: fileLoadOperation?.timing.wallMs ?? 0,
|
|
867
|
+
parseWallMs: (0, aggregator_1.operationUnionWithin)(phase, parseOperations),
|
|
868
|
+
otherWallMs: Math.max(0, phase.timing.wallMs - knownWallMs),
|
|
869
|
+
fileCount,
|
|
870
|
+
averageFileLoadMs,
|
|
871
|
+
hasFileLoadOutliers,
|
|
872
|
+
outliers,
|
|
873
|
+
operations: [...knownOperations, ...outliers],
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
function testDiscoveryAction(profile, breakdown) {
|
|
877
|
+
const discoveryOnly = profile.operation === "readTests" || profile.operation === "cli:list-tests";
|
|
878
|
+
const operationContext = discoveryOnly
|
|
879
|
+
? " This operation does not run tests, so reducing discovery directly reduces its total time."
|
|
880
|
+
: "";
|
|
881
|
+
const details = breakdown.hasFileLoadOutliers === undefined
|
|
882
|
+
? " Run with profiler.level 2 to check individual file-load outliers."
|
|
883
|
+
: "";
|
|
884
|
+
const largest = Math.max(breakdown.globWallMs, breakdown.fileLoadWallMs, breakdown.parseWallMs, breakdown.otherWallMs);
|
|
885
|
+
if (largest === breakdown.globWallMs && largest > 0) {
|
|
886
|
+
return `File matching/glob is the largest component. Narrow files/sets masks and exclude generated or artifact directories that cannot contain tests.${operationContext}${details}`;
|
|
887
|
+
}
|
|
888
|
+
if (largest === breakdown.fileLoadWallMs && largest > 0) {
|
|
889
|
+
if (breakdown.hasFileLoadOutliers) {
|
|
890
|
+
return `Inspect the reported file-load outliers and their imports or file-read listeners. Move repeated initialization out of module scope or load heavy dependencies lazily.${operationContext}`;
|
|
891
|
+
}
|
|
892
|
+
if (breakdown.hasFileLoadOutliers === false) {
|
|
893
|
+
const count = breakdown.fileCount === undefined ? "the selected files" : `${breakdown.fileCount} files`;
|
|
894
|
+
return `No file was at least 3× slower than the average; the cost is distributed across ${count}. Narrow the selected files/sets or move repeated per-file initialization out of module scope.${operationContext}`;
|
|
895
|
+
}
|
|
896
|
+
return `Loading the selected files is the largest component.${details}${operationContext}`;
|
|
897
|
+
}
|
|
898
|
+
if (largest === breakdown.parseWallMs && largest > 0) {
|
|
899
|
+
return `Grouping, parsing, or validation is the largest component. Reduce unnecessary browser/file combinations and inspect expensive test declaration or filtering logic.${operationContext}${details}`;
|
|
900
|
+
}
|
|
901
|
+
return `The retained components do not fully explain this phase. Set profiler.output and inspect its retained children.${operationContext}${details}`;
|
|
902
|
+
}
|
|
903
|
+
function majorPhaseFinding(profile, operation) {
|
|
904
|
+
const listeners = slowPhaseListeners(profile, operation);
|
|
905
|
+
const observation = [
|
|
906
|
+
`\`${operation.name}\` (${operation.process.type} process) took ${formatMs(operation.timing.wallMs)} (${formatPercent(operation.timing.wallMs / profile.durationMs)} of the run).`,
|
|
907
|
+
...(listeners.length
|
|
908
|
+
? ["Slow listeners or callbacks inside this phase:", ...listeners.map(phaseListenerObservation)]
|
|
909
|
+
: []),
|
|
910
|
+
].join("\n");
|
|
911
|
+
const evidence = [
|
|
912
|
+
{
|
|
913
|
+
metric: "wall",
|
|
914
|
+
value: operation.timing.wallMs,
|
|
915
|
+
unit: "ms",
|
|
916
|
+
threshold: policy_v1_1.ANALYSIS_POLICY_V1.majorPhase.minWallMs,
|
|
917
|
+
operationId: operation.id,
|
|
918
|
+
},
|
|
919
|
+
{
|
|
920
|
+
metric: "runShare",
|
|
921
|
+
value: operation.timing.wallMs / profile.durationMs,
|
|
922
|
+
unit: "ratio",
|
|
923
|
+
threshold: policy_v1_1.ANALYSIS_POLICY_V1.majorPhase.minRunShare,
|
|
924
|
+
operationId: operation.id,
|
|
925
|
+
},
|
|
926
|
+
];
|
|
927
|
+
for (const listener of listeners) {
|
|
928
|
+
evidence.push({ metric: "phaseContributor", value: listener.name, operationId: listener.id }, {
|
|
929
|
+
metric: "phaseContributorWall",
|
|
930
|
+
value: listener.timing.wallMs,
|
|
931
|
+
unit: "ms",
|
|
932
|
+
operationId: listener.id,
|
|
933
|
+
});
|
|
934
|
+
const source = formatSource(listener.source);
|
|
935
|
+
if (source) {
|
|
936
|
+
evidence.push({ metric: "phaseContributorSource", value: source, operationId: listener.id });
|
|
937
|
+
}
|
|
938
|
+
if (listener.timing.activeJsMs !== undefined) {
|
|
939
|
+
evidence.push({
|
|
940
|
+
metric: "phaseContributorActiveJs",
|
|
941
|
+
value: listener.timing.activeJsMs,
|
|
942
|
+
unit: "ms",
|
|
943
|
+
operationId: listener.id,
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
if (listener.timing.waitingMs !== undefined) {
|
|
947
|
+
evidence.push({
|
|
948
|
+
metric: "phaseContributorWaiting",
|
|
949
|
+
value: listener.timing.waitingMs,
|
|
950
|
+
unit: "ms",
|
|
951
|
+
operationId: listener.id,
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
return {
|
|
956
|
+
analyzer: { id: "major-phase-v1", version: 1 },
|
|
957
|
+
category: "phase",
|
|
958
|
+
severity: "warning",
|
|
959
|
+
observation,
|
|
960
|
+
evidence,
|
|
961
|
+
action: majorPhaseAction(profile, operation, listeners),
|
|
962
|
+
confidence: "high",
|
|
963
|
+
operationIds: [operation.id, ...listeners.map(listener => listener.id)],
|
|
964
|
+
entityIds: [operation.name],
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
function slowPhaseListeners(profile, phase) {
|
|
968
|
+
const operationsById = new Map(profile.timeline.map(operation => [operation.id, operation]));
|
|
969
|
+
return profile.timeline
|
|
970
|
+
.filter(operation => (operation.kind === "event.listener" || operation.kind === "user.callback") &&
|
|
971
|
+
!operation.name.startsWith("internal:"))
|
|
972
|
+
.filter(operation => hasAncestor(operation, phase.id, operationsById))
|
|
973
|
+
.filter(operation => operation.timing.wallMs >= policy_v1_1.ANALYSIS_POLICY_V1.majorPhase.minListenerWallMs &&
|
|
974
|
+
operation.timing.wallMs / phase.timing.wallMs >= policy_v1_1.ANALYSIS_POLICY_V1.majorPhase.minListenerShare)
|
|
975
|
+
.sort((left, right) => right.timing.wallMs - left.timing.wallMs)
|
|
976
|
+
.slice(0, policy_v1_1.ANALYSIS_POLICY_V1.majorPhase.maxListeners);
|
|
977
|
+
}
|
|
978
|
+
function hasAncestor(operation, ancestorId, operationsById) {
|
|
979
|
+
let parentId = operation.parentId;
|
|
980
|
+
const visited = new Set();
|
|
981
|
+
while (parentId && !visited.has(parentId)) {
|
|
982
|
+
if (parentId === ancestorId) {
|
|
983
|
+
return true;
|
|
984
|
+
}
|
|
985
|
+
visited.add(parentId);
|
|
986
|
+
parentId = operationsById.get(parentId)?.parentId;
|
|
987
|
+
}
|
|
988
|
+
return false;
|
|
989
|
+
}
|
|
990
|
+
function phaseListenerObservation(operation) {
|
|
991
|
+
const source = formatSource(operation.source);
|
|
992
|
+
const location = source ? ` at \`${source}\`` : "";
|
|
993
|
+
const type = operation.kind === "user.callback" ? "Callback" : "Listener";
|
|
994
|
+
const process = ` (${operation.process.type} process)`;
|
|
995
|
+
const timing = operation.timing.activeJsMs !== undefined && operation.timing.waitingMs !== undefined
|
|
996
|
+
? ` It spent ${formatMs(operation.timing.activeJsMs)} in active JS and ${formatMs(operation.timing.waitingMs)} waiting.`
|
|
997
|
+
: "";
|
|
998
|
+
return `• ${type} \`${operation.name}\`${process}${location} took ${formatMs(operation.timing.wallMs)}.${timing}`;
|
|
999
|
+
}
|
|
1000
|
+
function majorPhaseAction(profile, operation, listeners) {
|
|
1001
|
+
if (!listeners.length) {
|
|
1002
|
+
if (profile.level < 3) {
|
|
1003
|
+
return `Run with profiler.level ${profile.level + 1} to retain more child operations inside \`${operation.name}\`; the current profile does not identify a specific owner.`;
|
|
1004
|
+
}
|
|
1005
|
+
return `Set profiler.output and inspect unattributed time inside \`${operation.name}\`. No retained user-owned child materially explains this phase; if it stays slow without custom plugins, attach the profile to a Testplane issue.`;
|
|
1006
|
+
}
|
|
1007
|
+
return "Open the reported listener or callback sources, or locate their registrations by the displayed names. If active JS dominates, reduce synchronous or CPU-heavy work; if waiting dominates, inspect awaited I/O or timers. Parallelize only independent operations.";
|
|
1008
|
+
}
|
|
1009
|
+
function listenerFinding(aggregate, profile, slowest, shortLocation) {
|
|
1010
|
+
const finding = aggregateFinding("event-listener-v1", "event-listener", aggregate, profile, listenerAction(profile.level, aggregate.name, slowest, shortLocation));
|
|
1011
|
+
if (!slowest) {
|
|
1012
|
+
return finding;
|
|
1013
|
+
}
|
|
1014
|
+
const source = formatSource(slowest?.source);
|
|
1015
|
+
const location = source ? ` at \`${source}\`` : " (source unavailable)";
|
|
1016
|
+
finding.observation += ` Slowest retained call${location} took ${formatMs(slowest.timing.wallMs)}.`;
|
|
1017
|
+
if (source) {
|
|
1018
|
+
finding.evidence.push({ metric: "source", value: source, operationId: slowest.id });
|
|
1019
|
+
}
|
|
1020
|
+
if (slowest.timing.activeJsMs !== undefined && slowest.timing.waitingMs !== undefined) {
|
|
1021
|
+
finding.evidence.push({
|
|
1022
|
+
metric: "activeJs",
|
|
1023
|
+
value: slowest.timing.activeJsMs,
|
|
1024
|
+
unit: "ms",
|
|
1025
|
+
operationId: slowest.id,
|
|
1026
|
+
}, {
|
|
1027
|
+
metric: "waiting",
|
|
1028
|
+
value: slowest.timing.waitingMs,
|
|
1029
|
+
unit: "ms",
|
|
1030
|
+
operationId: slowest.id,
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
return finding;
|
|
1034
|
+
}
|
|
1035
|
+
function listenerAction(level, name, operation, shortLocation) {
|
|
1036
|
+
const owner = listenerOwner(name, shortLocation);
|
|
1037
|
+
const activeJsMs = operation?.timing.activeJsMs;
|
|
1038
|
+
const waitingMs = operation?.timing.waitingMs;
|
|
1039
|
+
if (activeJsMs === undefined || waitingMs === undefined) {
|
|
1040
|
+
return level < 3
|
|
1041
|
+
? `${owner}: run with profiler.level 3 to see what happened during this listener call.`
|
|
1042
|
+
: `${owner}: the detailed activity breakdown was not retained; inspect what this listener does during its slow call.`;
|
|
1043
|
+
}
|
|
1044
|
+
if (waitingMs > activeJsMs) {
|
|
1045
|
+
return `${owner}: waiting dominates the slowest retained call; inspect awaited I/O or timers and remove avoidable serial waits.`;
|
|
1046
|
+
}
|
|
1047
|
+
if (activeJsMs > waitingMs) {
|
|
1048
|
+
return `${owner}: active JS dominates the slowest retained call; reduce synchronous CPU-heavy work or move it off the listener's critical path.`;
|
|
1049
|
+
}
|
|
1050
|
+
return `${owner}: active JS and waiting contribute equally; inspect both awaited I/O or timers and synchronous CPU-heavy work.`;
|
|
1051
|
+
}
|
|
1052
|
+
function listenerOwner(name, shortLocation) {
|
|
1053
|
+
return `\`${name}\` (${shortLocation ?? "source unavailable"})`;
|
|
1054
|
+
}
|
|
1055
|
+
function shortestUniqueSourceLocations(sources) {
|
|
1056
|
+
const paths = sources.map(source => source?.file?.replaceAll("\\", "/"));
|
|
1057
|
+
const uniquePaths = [...new Set(paths.filter((file) => Boolean(file)))];
|
|
1058
|
+
const suffixCounts = new Map();
|
|
1059
|
+
for (const file of uniquePaths) {
|
|
1060
|
+
const parts = file.split("/").filter(Boolean);
|
|
1061
|
+
for (let depth = 1; depth <= parts.length; depth += 1) {
|
|
1062
|
+
const suffix = parts.slice(-depth).join("/");
|
|
1063
|
+
suffixCounts.set(suffix, (suffixCounts.get(suffix) ?? 0) + 1);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
const suffixes = new Map(uniquePaths.map(file => {
|
|
1067
|
+
const parts = file.split("/").filter(Boolean);
|
|
1068
|
+
const suffix = parts
|
|
1069
|
+
.map((_, index) => parts.slice(-(index + 1)).join("/"))
|
|
1070
|
+
.find(candidate => suffixCounts.get(candidate) === 1);
|
|
1071
|
+
return [file, suffix ?? parts.join("/")];
|
|
1072
|
+
}));
|
|
1073
|
+
return sources.map((source, index) => {
|
|
1074
|
+
const file = paths[index];
|
|
1075
|
+
if (!file) {
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
const line = source?.line;
|
|
1079
|
+
const column = source?.column;
|
|
1080
|
+
return `${suffixes.get(file)}${line === undefined ? "" : `:${line}`}${column === undefined ? "" : `:${column}`}`;
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
function testFileAction(profile, operation) {
|
|
1084
|
+
if (profile.level < 3) {
|
|
1085
|
+
return "Run with profiler.level 3 to identify heavy dependencies. If no slow dependency is reported, inspect synchronous or awaited module-scope initialization in this test file.";
|
|
1086
|
+
}
|
|
1087
|
+
const dependency = profile.timeline
|
|
1088
|
+
.filter(candidate => candidate.kind === "module.load" &&
|
|
1089
|
+
candidate.attributes.ownerFile === operation.name &&
|
|
1090
|
+
candidate.name !== operation.name &&
|
|
1091
|
+
candidate.attributes.cacheHit !== true &&
|
|
1092
|
+
!isFrameworkModule(String(candidate.attributes.module ?? candidate.name)))
|
|
1093
|
+
.sort((left, right) => right.timing.wallMs - left.timing.wallMs)[0];
|
|
1094
|
+
return dependency
|
|
1095
|
+
? `Inspect \`${dependency.name}\`, the slowest retained uncached dependency of this file. Move heavy initialization out of module scope or load it lazily.`
|
|
1096
|
+
: "No slow dependency was retained for this file. Inspect synchronous or awaited module-scope initialization in the file itself.";
|
|
1097
|
+
}
|
|
1098
|
+
function moduleDependencyAction(ownerFile) {
|
|
1099
|
+
return `In \`${ownerFile}\`, load this dependency lazily or import a lighter entry point. Reduce its initialization work if you own it. Reuse initialized state through the module cache only within the same worker process.`;
|
|
1100
|
+
}
|
|
1101
|
+
function commandFinding(aggregate, profile) {
|
|
1102
|
+
const finding = aggregateFinding("browser-command-v1", "browser-command", aggregate, profile, "Inspect the slowest retained calls and their test context. Reduce redundant calls or the work performed by each call; for wait-like commands, prefer a condition-based wait.");
|
|
1103
|
+
const slowest = retainedOperationsForAggregate(profile, aggregate)[0];
|
|
1104
|
+
const context = slowest ? commandContext(profile, slowest) : "";
|
|
1105
|
+
const example = slowest ? ` Slowest retained call took ${formatMs(slowest.timing.wallMs)}${context}.` : "";
|
|
1106
|
+
const source = formatSource(slowest?.source);
|
|
1107
|
+
const registration = source ? ` It was registered at \`${source}\`.` : "";
|
|
1108
|
+
finding.observation = `\`${aggregate.name}\` used ${formatMs(aggregate.totalWallMs)} across ${aggregate.count} call(s) (${formatMs(aggregate.meanWallMs)} average).${example}${registration}`;
|
|
1109
|
+
if (source && slowest) {
|
|
1110
|
+
finding.evidence.push({ metric: "source", value: source, operationId: slowest.id });
|
|
1111
|
+
}
|
|
1112
|
+
return finding;
|
|
1113
|
+
}
|
|
1114
|
+
function commandContext(profile, operation) {
|
|
1115
|
+
const runnable = findAncestor(profile, operation, candidate => [
|
|
1116
|
+
"test.body",
|
|
1117
|
+
"test.hook",
|
|
1118
|
+
"test.beforeEach.total",
|
|
1119
|
+
"test.afterEach.total",
|
|
1120
|
+
"worker.test-attempt",
|
|
1121
|
+
"test.attempt",
|
|
1122
|
+
].includes(candidate.kind));
|
|
1123
|
+
const test = runnable ? ` under \`${runnable.name}\`` : "";
|
|
1124
|
+
const browser = operation.context.browserId ? ` on \`${operation.context.browserId}\`` : "";
|
|
1125
|
+
return `${test}${browser}`;
|
|
1126
|
+
}
|
|
1127
|
+
function findAncestor(profile, operation, matches) {
|
|
1128
|
+
let parentId = operation.parentId;
|
|
1129
|
+
const visited = new Set();
|
|
1130
|
+
while (parentId && !visited.has(parentId)) {
|
|
1131
|
+
visited.add(parentId);
|
|
1132
|
+
const parent = profile.timeline.find(candidate => candidate.id === parentId);
|
|
1133
|
+
if (!parent) {
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
if (matches(parent)) {
|
|
1137
|
+
return parent;
|
|
1138
|
+
}
|
|
1139
|
+
parentId = parent.parentId;
|
|
1140
|
+
}
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
function retainedOperationsForAggregate(profile, aggregate) {
|
|
1144
|
+
return profile.timeline
|
|
1145
|
+
.filter(operation => operation.kind === aggregate.kind && operation.name === aggregate.name)
|
|
1146
|
+
.sort((left, right) => right.timing.wallMs - left.timing.wallMs)
|
|
1147
|
+
.slice(0, 3);
|
|
1148
|
+
}
|
|
1149
|
+
function formatSource(source) {
|
|
1150
|
+
const location = source?.file
|
|
1151
|
+
? `${source.file}${source.line === undefined ? "" : `:${source.line}`}${source.column === undefined ? "" : `:${source.column}`}`
|
|
1152
|
+
: undefined;
|
|
1153
|
+
if (source?.plugin && location) {
|
|
1154
|
+
return `${source.plugin} (${location})`;
|
|
1155
|
+
}
|
|
1156
|
+
return location ?? source?.plugin;
|
|
1157
|
+
}
|
|
1158
|
+
const dedupeByNameMaxWall = (operations) => {
|
|
1159
|
+
const byName = new Map();
|
|
1160
|
+
for (const operation of operations) {
|
|
1161
|
+
const existing = byName.get(operation.name);
|
|
1162
|
+
if (!existing || operation.timing.wallMs > existing.timing.wallMs) {
|
|
1163
|
+
byName.set(operation.name, operation);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
return [...byName.values()];
|
|
1167
|
+
};
|
|
1168
|
+
const isFrameworkModule = (modulePath) => modulePath.includes("node_modules/testplane/");
|
|
1169
|
+
const groupOperations = (operations, getKey) => {
|
|
1170
|
+
const result = new Map();
|
|
1171
|
+
for (const operation of operations) {
|
|
1172
|
+
const key = getKey(operation);
|
|
1173
|
+
const group = result.get(key);
|
|
1174
|
+
if (group) {
|
|
1175
|
+
group.push(operation);
|
|
1176
|
+
}
|
|
1177
|
+
else {
|
|
1178
|
+
result.set(key, [operation]);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
return result;
|
|
1182
|
+
};
|
|
1183
|
+
const operationUnionWall = (profile, aggregate) => (0, aggregator_1.unionDuration)(profile.timeline
|
|
1184
|
+
.filter(operation => operation.kind === aggregate.kind && operation.name === aggregate.name)
|
|
1185
|
+
.map(operation => [operation.startOffsetMs, operation.startOffsetMs + operation.timing.wallMs]));
|
|
1186
|
+
const durationAtConcurrency = (operations, minimumConcurrency) => {
|
|
1187
|
+
const boundaries = operations
|
|
1188
|
+
.flatMap(operation => [
|
|
1189
|
+
{ offset: operation.startOffsetMs, delta: 1 },
|
|
1190
|
+
{ offset: operation.startOffsetMs + operation.timing.wallMs, delta: -1 },
|
|
1191
|
+
])
|
|
1192
|
+
.sort((left, right) => left.offset - right.offset || left.delta - right.delta);
|
|
1193
|
+
let active = 0;
|
|
1194
|
+
let previousOffset = boundaries[0]?.offset ?? 0;
|
|
1195
|
+
let durationMs = 0;
|
|
1196
|
+
for (const boundary of boundaries) {
|
|
1197
|
+
if (active >= minimumConcurrency) {
|
|
1198
|
+
durationMs += Math.max(0, boundary.offset - previousOffset);
|
|
1199
|
+
}
|
|
1200
|
+
active += boundary.delta;
|
|
1201
|
+
previousOffset = boundary.offset;
|
|
1202
|
+
}
|
|
1203
|
+
return durationMs;
|
|
1204
|
+
};
|
|
1205
|
+
const metricValue = (profile, name, dimensions = {}) => profile.aggregates.metrics.find(metric => metric.name === name &&
|
|
1206
|
+
Object.entries(dimensions).every(([key, value]) => metric.dimensions[key] === value))?.value;
|
|
1207
|
+
const sampledAggregateValue = (profile, name, field, attributes = {}) => {
|
|
1208
|
+
const aggregate = findAggregate(profile, name, attributes);
|
|
1209
|
+
return (aggregate?.count ?? 0) >= policy_v1_1.ANALYSIS_POLICY_V1.resources.minSamples ? aggregate?.[field] : undefined;
|
|
1210
|
+
};
|
|
1211
|
+
const findAggregate = (profile, name, attributes = {}) => profile.aggregates.byKind.find(aggregate => aggregate.kind === "metric.sample" &&
|
|
1212
|
+
aggregate.name === name &&
|
|
1213
|
+
Object.entries(attributes).every(([key, value]) => aggregate.attributes?.[key] === value));
|
|
1214
|
+
const maxAggregate = (profile, name, field, minSamples = 0) => profile.aggregates.byKind
|
|
1215
|
+
.filter(aggregate => aggregate.kind === "metric.sample" && aggregate.name === name)
|
|
1216
|
+
.filter(aggregate => aggregate.count >= minSamples && aggregate[field] !== undefined)
|
|
1217
|
+
.sort((left, right) => (right[field] ?? 0) - (left[field] ?? 0))[0];
|
|
1218
|
+
const maxMetricValue = (profile, name, dimensions = {}) => {
|
|
1219
|
+
const values = profile.aggregates.metrics
|
|
1220
|
+
.filter(metric => metric.name === name &&
|
|
1221
|
+
Object.entries(dimensions).every(([key, value]) => metric.dimensions[key] === value))
|
|
1222
|
+
.map(metric => metric.value);
|
|
1223
|
+
return values.length ? Math.max(...values) : undefined;
|
|
1224
|
+
};
|
|
1225
|
+
const formatMs = (milliseconds) => milliseconds >= 1000 ? `${(milliseconds / 1000).toFixed(1)}s` : `${milliseconds.toFixed(0)}ms`;
|
|
1226
|
+
const formatPercent = (ratio) => `${Math.round(ratio * 100)}%`;
|
|
1227
|
+
const capitalize = (value) => (value ? `${value[0].toUpperCase()}${value.slice(1)}` : value);
|
|
1228
|
+
//# sourceMappingURL=analyzers.js.map
|