@yasserkhanorg/e2e-agents 0.3.3 → 0.3.5
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/README.md +66 -3
- package/dist/agent/ai_flow_analysis.d.ts +12 -0
- package/dist/agent/ai_flow_analysis.d.ts.map +1 -0
- package/dist/agent/ai_flow_analysis.js +326 -0
- package/dist/agent/ai_mapping.d.ts +14 -0
- package/dist/agent/ai_mapping.d.ts.map +1 -0
- package/dist/agent/ai_mapping.js +374 -0
- package/dist/agent/config.d.ts +32 -0
- package/dist/agent/config.d.ts.map +1 -1
- package/dist/agent/config.js +187 -1
- package/dist/agent/flow_catalog.d.ts.map +1 -1
- package/dist/agent/flow_catalog.js +10 -1
- package/dist/agent/operational_insights.d.ts +1 -1
- package/dist/agent/operational_insights.d.ts.map +1 -1
- package/dist/agent/operational_insights.js +2 -1
- package/dist/agent/pipeline.d.ts +2 -0
- package/dist/agent/pipeline.d.ts.map +1 -1
- package/dist/agent/pipeline.js +409 -68
- package/dist/agent/plan.d.ts +40 -0
- package/dist/agent/plan.d.ts.map +1 -1
- package/dist/agent/plan.js +159 -4
- package/dist/agent/report.d.ts +13 -2
- package/dist/agent/report.d.ts.map +1 -1
- package/dist/agent/report.js +9 -0
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +246 -19
- package/dist/agent/tests.d.ts +1 -1
- package/dist/agent/tests.d.ts.map +1 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +1 -0
- package/dist/cli.js +97 -4
- package/dist/esm/agent/ai_flow_analysis.js +323 -0
- package/dist/esm/agent/ai_mapping.js +371 -0
- package/dist/esm/agent/config.js +187 -1
- package/dist/esm/agent/flow_catalog.js +10 -1
- package/dist/esm/agent/operational_insights.js +2 -1
- package/dist/esm/agent/pipeline.js +409 -68
- package/dist/esm/agent/plan.js +158 -5
- package/dist/esm/agent/report.js +9 -0
- package/dist/esm/agent/runner.js +246 -19
- package/dist/esm/api.js +2 -1
- package/dist/esm/cli.js +98 -5
- package/dist/esm/provider_factory.js +7 -3
- package/dist/provider_factory.d.ts.map +1 -1
- package/dist/provider_factory.js +7 -3
- package/package.json +4 -1
- package/schemas/impact.schema.json +40 -3
- package/schemas/plan.schema.json +48 -0
package/dist/cli.js
CHANGED
|
@@ -76,6 +76,8 @@ function printUsage() {
|
|
|
76
76
|
'Options:',
|
|
77
77
|
' --config <path> Path to e2e-ai-agents.config.json (auto-discovered if present)',
|
|
78
78
|
' --path <app-root> Path to the web app (required)',
|
|
79
|
+
' --profile <name> default | mattermost',
|
|
80
|
+
' --mattermost Shortcut for --profile mattermost',
|
|
79
81
|
' --tests-root <path> Path to tests root (optional)',
|
|
80
82
|
' --framework <name> auto | playwright | cypress | selenium',
|
|
81
83
|
' --patterns <globs> Comma-separated test patterns',
|
|
@@ -89,20 +91,27 @@ function printUsage() {
|
|
|
89
91
|
' --pipeline-base-url Base URL for Playwright runs',
|
|
90
92
|
' --pipeline-browser Browser: chrome|chromium|firefox|webkit',
|
|
91
93
|
' --pipeline-headless Run in headless mode',
|
|
94
|
+
' --pipeline-headed Run in headed mode',
|
|
92
95
|
' --pipeline-project Playwright project name',
|
|
93
96
|
' --pipeline-parallel Enable parallel mode in generator',
|
|
94
97
|
' --pipeline-dry-run Do not execute pipeline (report only)',
|
|
95
98
|
' --pipeline-mcp Use Playwright MCP server for exploration/healing',
|
|
96
99
|
' --pipeline-mcp-allow-fallback Allow non-MCP fallback if official MCP setup fails',
|
|
100
|
+
' --pipeline-mcp-only Require MCP for UI exploration (fail if unavailable)',
|
|
101
|
+
' --pipeline-mcp-timeout-ms <n> Timeout per MCP CLI invocation in milliseconds',
|
|
102
|
+
' --pipeline-mcp-retries <n> Retry count for retryable MCP CLI failures',
|
|
97
103
|
' --spec <path> Optional spec PDF for context',
|
|
98
104
|
' --since <git-ref> Git ref for impact analysis (default HEAD~1)',
|
|
99
105
|
' --time <minutes> Time limit in minutes',
|
|
100
106
|
' --budget-usd <amount> Max LLM budget in USD',
|
|
101
107
|
' --budget-tokens <n> Max LLM tokens',
|
|
108
|
+
' --llm-provider <name> LLM provider: auto | anthropic | openai | ollama',
|
|
102
109
|
' --policy-min-confidence <n> Minimum confidence for targeted suite',
|
|
103
110
|
' --policy-safe-merge-confidence <n> Confidence needed for safe-to-merge',
|
|
104
111
|
' --policy-force-full-on-warnings <n> Escalate to full at warning count',
|
|
105
112
|
' --policy-risky-patterns <globs> Comma-separated risky file globs',
|
|
113
|
+
' --policy-enforcement-mode <mode> advisory | warn | block',
|
|
114
|
+
' --policy-block-actions <actions> Comma-separated CI actions to block/warn',
|
|
106
115
|
' --ci-comment-path <path> Write CI markdown summary',
|
|
107
116
|
' --github-output <path> Write GitHub Actions outputs',
|
|
108
117
|
' --fail-on-must-add-tests Exit non-zero on must-add-tests decision',
|
|
@@ -170,6 +179,17 @@ function parseArgs(argv) {
|
|
|
170
179
|
i += 1;
|
|
171
180
|
continue;
|
|
172
181
|
}
|
|
182
|
+
if (arg === '--profile' && next) {
|
|
183
|
+
if (next === 'default' || next === 'mattermost') {
|
|
184
|
+
parsed.profile = next;
|
|
185
|
+
}
|
|
186
|
+
i += 1;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (arg === '--mattermost') {
|
|
190
|
+
parsed.profile = 'mattermost';
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
173
193
|
if (arg === '--tests-root' && next) {
|
|
174
194
|
parsed.testsRoot = next;
|
|
175
195
|
i += 1;
|
|
@@ -216,6 +236,20 @@ function parseArgs(argv) {
|
|
|
216
236
|
parsed.pipelineMcpAllowFallback = true;
|
|
217
237
|
continue;
|
|
218
238
|
}
|
|
239
|
+
if (arg === '--pipeline-mcp-only') {
|
|
240
|
+
parsed.pipelineMcpOnly = true;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (arg === '--pipeline-mcp-timeout-ms' && next) {
|
|
244
|
+
parsed.pipelineMcpTimeoutMs = Number(next);
|
|
245
|
+
i += 1;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (arg === '--pipeline-mcp-retries' && next) {
|
|
249
|
+
parsed.pipelineMcpRetries = Number(next);
|
|
250
|
+
i += 1;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
219
253
|
if (arg === '--pipeline-scenarios' && next) {
|
|
220
254
|
const value = Number(next);
|
|
221
255
|
if (Number.isFinite(value)) {
|
|
@@ -246,6 +280,10 @@ function parseArgs(argv) {
|
|
|
246
280
|
parsed.pipelineHeadless = true;
|
|
247
281
|
continue;
|
|
248
282
|
}
|
|
283
|
+
if (arg === '--pipeline-headed') {
|
|
284
|
+
parsed.pipelineHeadless = false;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
249
287
|
if (arg === '--pipeline-project' && next) {
|
|
250
288
|
parsed.pipelineProject = next;
|
|
251
289
|
i += 1;
|
|
@@ -293,6 +331,11 @@ function parseArgs(argv) {
|
|
|
293
331
|
i += 1;
|
|
294
332
|
continue;
|
|
295
333
|
}
|
|
334
|
+
if (arg === '--llm-provider' && next) {
|
|
335
|
+
parsed.llmProvider = next;
|
|
336
|
+
i += 1;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
296
339
|
if (arg === '--policy-min-confidence' && next) {
|
|
297
340
|
const value = Number(next);
|
|
298
341
|
if (Number.isFinite(value)) {
|
|
@@ -322,6 +365,21 @@ function parseArgs(argv) {
|
|
|
322
365
|
i += 1;
|
|
323
366
|
continue;
|
|
324
367
|
}
|
|
368
|
+
if (arg === '--policy-enforcement-mode' && next) {
|
|
369
|
+
if (next === 'advisory' || next === 'warn' || next === 'block') {
|
|
370
|
+
parsed.policyEnforcementMode = next;
|
|
371
|
+
}
|
|
372
|
+
i += 1;
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (arg === '--policy-block-actions' && next) {
|
|
376
|
+
parsed.policyBlockActions = next
|
|
377
|
+
.split(',')
|
|
378
|
+
.map((value) => value.trim())
|
|
379
|
+
.filter((value) => (value === 'run-now' || value === 'must-add-tests' || value === 'safe-to-merge'));
|
|
380
|
+
i += 1;
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
325
383
|
if (arg === '--ci-comment-path' && next) {
|
|
326
384
|
parsed.ciCommentPath = next;
|
|
327
385
|
i += 1;
|
|
@@ -450,8 +508,10 @@ async function main() {
|
|
|
450
508
|
}
|
|
451
509
|
const { config } = (0, config_js_1.resolveConfig)(process.cwd(), autoConfig, {
|
|
452
510
|
path: args.path,
|
|
511
|
+
profile: args.profile,
|
|
453
512
|
testsRoot: args.testsRoot,
|
|
454
513
|
mode: 'impact',
|
|
514
|
+
llmProvider: args.llmProvider,
|
|
455
515
|
});
|
|
456
516
|
const reportRoot = config.testsRoot || config.path;
|
|
457
517
|
const raw = JSON.parse((0, fs_1.readFileSync)(args.feedbackInputPath, 'utf-8'));
|
|
@@ -485,9 +545,11 @@ async function main() {
|
|
|
485
545
|
}
|
|
486
546
|
const { config } = (0, config_js_1.resolveConfig)(process.cwd(), autoConfig, {
|
|
487
547
|
path: args.path,
|
|
548
|
+
profile: args.profile,
|
|
488
549
|
testsRoot: args.testsRoot,
|
|
489
550
|
mode: 'impact',
|
|
490
551
|
gitSince: args.gitSince,
|
|
552
|
+
llmProvider: args.llmProvider,
|
|
491
553
|
});
|
|
492
554
|
const reportRoot = config.testsRoot || config.path;
|
|
493
555
|
const output = (0, traceability_capture_js_1.captureTraceabilityInput)({
|
|
@@ -526,8 +588,10 @@ async function main() {
|
|
|
526
588
|
}
|
|
527
589
|
const { config } = (0, config_js_1.resolveConfig)(process.cwd(), autoConfig, {
|
|
528
590
|
path: args.path,
|
|
591
|
+
profile: args.profile,
|
|
529
592
|
testsRoot: args.testsRoot,
|
|
530
593
|
mode: 'impact',
|
|
594
|
+
llmProvider: args.llmProvider,
|
|
531
595
|
});
|
|
532
596
|
const reportRoot = config.testsRoot || config.path;
|
|
533
597
|
const raw = JSON.parse((0, fs_1.readFileSync)(args.traceabilityInputPath, 'utf-8'));
|
|
@@ -560,8 +624,10 @@ async function main() {
|
|
|
560
624
|
}
|
|
561
625
|
const { config } = (0, config_js_1.resolveConfig)(process.cwd(), autoConfig, {
|
|
562
626
|
path: args.path,
|
|
627
|
+
profile: args.profile,
|
|
563
628
|
testsRoot: args.testsRoot,
|
|
564
629
|
mode: 'gap',
|
|
630
|
+
llmProvider: args.llmProvider,
|
|
565
631
|
});
|
|
566
632
|
const result = (0, handoff_js_1.finalizeGeneratedTests)({
|
|
567
633
|
appPath: config.path,
|
|
@@ -600,6 +666,7 @@ async function main() {
|
|
|
600
666
|
}
|
|
601
667
|
const { config } = (0, config_js_1.resolveConfig)(process.cwd(), autoConfig, {
|
|
602
668
|
path: args.path,
|
|
669
|
+
profile: args.profile,
|
|
603
670
|
testsRoot: args.testsRoot,
|
|
604
671
|
mode: 'gap',
|
|
605
672
|
framework: args.framework,
|
|
@@ -626,7 +693,9 @@ async function main() {
|
|
|
626
693
|
dryRun: args.pipelineDryRun,
|
|
627
694
|
mcp: args.pipelineMcp,
|
|
628
695
|
mcpAllowFallback: args.pipelineMcpAllowFallback,
|
|
696
|
+
mcpOnly: args.pipelineMcpOnly,
|
|
629
697
|
},
|
|
698
|
+
llmProvider: args.llmProvider,
|
|
630
699
|
});
|
|
631
700
|
if (args.allowFallback) {
|
|
632
701
|
config.impact.allowFallback = true;
|
|
@@ -713,6 +782,7 @@ async function main() {
|
|
|
713
782
|
}
|
|
714
783
|
const { config } = (0, config_js_1.resolveConfig)(process.cwd(), autoConfig, {
|
|
715
784
|
path: args.path,
|
|
785
|
+
profile: args.profile,
|
|
716
786
|
testsRoot: args.testsRoot,
|
|
717
787
|
mode: 'gap',
|
|
718
788
|
framework: args.framework,
|
|
@@ -728,7 +798,9 @@ async function main() {
|
|
|
728
798
|
dryRun: args.pipelineDryRun,
|
|
729
799
|
mcp: args.pipelineMcp,
|
|
730
800
|
mcpAllowFallback: args.pipelineMcpAllowFallback,
|
|
801
|
+
mcpOnly: args.pipelineMcpOnly,
|
|
731
802
|
},
|
|
803
|
+
llmProvider: args.llmProvider,
|
|
732
804
|
});
|
|
733
805
|
const reportRoot = config.testsRoot || config.path;
|
|
734
806
|
const unstableSpecs = (0, playwright_report_js_1.extractPlaywrightUnstableSpecs)(args.traceabilityReportPath, [reportRoot, config.path]);
|
|
@@ -762,8 +834,10 @@ async function main() {
|
|
|
762
834
|
process.exit(1);
|
|
763
835
|
}
|
|
764
836
|
const forcePipelineFromApproval = args.command === 'approve-and-generate' || args.command === 'generate';
|
|
765
|
-
const
|
|
837
|
+
const forceAIPipelineFromApproval = args.command === 'approve-and-generate' || args.command === 'generate';
|
|
838
|
+
const { config, configPath } = (0, config_js_1.resolveConfig)(process.cwd(), autoConfig, {
|
|
766
839
|
path: args.path,
|
|
840
|
+
profile: args.profile,
|
|
767
841
|
testsRoot: args.testsRoot,
|
|
768
842
|
mode: (args.command === 'gap' || args.command === 'approve-and-generate' || args.command === 'generate') ? 'gap' : 'impact',
|
|
769
843
|
framework: args.framework,
|
|
@@ -778,6 +852,7 @@ async function main() {
|
|
|
778
852
|
flowCatalogPath: args.flowCatalogPath,
|
|
779
853
|
specPDF: args.specPDF,
|
|
780
854
|
gitSince: args.gitSince,
|
|
855
|
+
llmProvider: args.llmProvider,
|
|
781
856
|
pipeline: (args.pipeline || forcePipelineFromApproval)
|
|
782
857
|
? {
|
|
783
858
|
enabled: true,
|
|
@@ -789,19 +864,26 @@ async function main() {
|
|
|
789
864
|
project: args.pipelineProject,
|
|
790
865
|
parallel: args.pipelineParallel,
|
|
791
866
|
dryRun: args.pipelineDryRun,
|
|
792
|
-
mcp: args.pipelineMcp !== undefined ? args.pipelineMcp :
|
|
867
|
+
mcp: args.pipelineMcp !== undefined ? args.pipelineMcp : forceAIPipelineFromApproval,
|
|
793
868
|
mcpAllowFallback: args.pipelineMcpAllowFallback,
|
|
869
|
+
mcpOnly: args.pipelineMcpOnly !== undefined ? args.pipelineMcpOnly : forceAIPipelineFromApproval,
|
|
870
|
+
mcpCommandTimeoutMs: args.pipelineMcpTimeoutMs,
|
|
871
|
+
mcpRetries: args.pipelineMcpRetries,
|
|
794
872
|
}
|
|
795
873
|
: undefined,
|
|
796
874
|
policy: args.policyMinConfidence !== undefined ||
|
|
797
875
|
args.policySafeMergeConfidence !== undefined ||
|
|
798
876
|
args.policyWarningsThreshold !== undefined ||
|
|
799
|
-
(args.policyRiskyPatterns && args.policyRiskyPatterns.length > 0)
|
|
877
|
+
(args.policyRiskyPatterns && args.policyRiskyPatterns.length > 0) ||
|
|
878
|
+
args.policyEnforcementMode !== undefined ||
|
|
879
|
+
(args.policyBlockActions && args.policyBlockActions.length > 0)
|
|
800
880
|
? {
|
|
801
881
|
minConfidenceForTargeted: args.policyMinConfidence,
|
|
802
882
|
safeMergeMinConfidence: args.policySafeMergeConfidence,
|
|
803
883
|
forceFullOnWarningsAtOrAbove: args.policyWarningsThreshold,
|
|
804
884
|
riskyFilePatterns: args.policyRiskyPatterns,
|
|
885
|
+
enforcementMode: args.policyEnforcementMode,
|
|
886
|
+
blockOnActions: args.policyBlockActions,
|
|
805
887
|
}
|
|
806
888
|
: undefined,
|
|
807
889
|
});
|
|
@@ -825,29 +907,39 @@ async function main() {
|
|
|
825
907
|
appPath: config.path,
|
|
826
908
|
testsRoot: reportRoot,
|
|
827
909
|
sinceRef: config.git.since,
|
|
910
|
+
configPath,
|
|
828
911
|
});
|
|
829
912
|
const plan = (0, operational_insights_js_1.applyOperationalInsights)(withActions, reportRoot);
|
|
830
913
|
const planPath = (0, plan_js_1.writePlanReport)(reportRoot, plan);
|
|
831
914
|
const summaryMarkdown = (0, plan_js_1.renderCiSummaryMarkdown)(plan);
|
|
832
915
|
const summaryPath = (0, plan_js_1.writeCiSummary)(reportRoot, summaryMarkdown, args.ciCommentPath);
|
|
916
|
+
const metrics = (0, plan_js_1.appendPlanMetrics)(reportRoot, plan);
|
|
833
917
|
const ghaOutput = args.githubOutputPath || process.env.GITHUB_OUTPUT;
|
|
834
918
|
if (ghaOutput) {
|
|
835
919
|
(0, fs_1.appendFileSync)(ghaOutput, `run_set=${plan.runSet}\n`);
|
|
836
920
|
(0, fs_1.appendFileSync)(ghaOutput, `action=${plan.decision.action}\n`);
|
|
837
921
|
(0, fs_1.appendFileSync)(ghaOutput, `confidence=${plan.confidence}\n`);
|
|
922
|
+
(0, fs_1.appendFileSync)(ghaOutput, `enforcement_mode=${plan.enforcement.mode}\n`);
|
|
923
|
+
(0, fs_1.appendFileSync)(ghaOutput, `enforcement_should_fail=${plan.enforcement.shouldFail}\n`);
|
|
838
924
|
(0, fs_1.appendFileSync)(ghaOutput, `recommended_tests_count=${plan.recommendedTests.length}\n`);
|
|
839
925
|
(0, fs_1.appendFileSync)(ghaOutput, `required_new_tests_count=${plan.requiredNewTests.length}\n`);
|
|
840
926
|
(0, fs_1.appendFileSync)(ghaOutput, `plan_path=${planPath}\n`);
|
|
841
927
|
(0, fs_1.appendFileSync)(ghaOutput, `summary_path=${summaryPath}\n`);
|
|
928
|
+
(0, fs_1.appendFileSync)(ghaOutput, `metrics_events_path=${metrics.eventsPath}\n`);
|
|
929
|
+
(0, fs_1.appendFileSync)(ghaOutput, `metrics_summary_path=${metrics.summaryPath}\n`);
|
|
842
930
|
}
|
|
843
931
|
// eslint-disable-next-line no-console
|
|
844
932
|
console.log(`Suggested run set: ${plan.runSet} (confidence ${plan.confidence})`);
|
|
845
933
|
// eslint-disable-next-line no-console
|
|
846
934
|
console.log(`Decision: ${plan.decision.action} - ${plan.decision.summary}`);
|
|
847
935
|
// eslint-disable-next-line no-console
|
|
936
|
+
console.log(`Enforcement: ${plan.enforcement.mode} (shouldFail=${plan.enforcement.shouldFail})`);
|
|
937
|
+
// eslint-disable-next-line no-console
|
|
848
938
|
console.log(`Plan data: ${planPath}`);
|
|
849
939
|
// eslint-disable-next-line no-console
|
|
850
940
|
console.log(`CI summary: ${summaryPath}`);
|
|
941
|
+
// eslint-disable-next-line no-console
|
|
942
|
+
console.log(`Plan metrics: ${metrics.summaryPath}`);
|
|
851
943
|
if (plan.nextActions) {
|
|
852
944
|
// eslint-disable-next-line no-console
|
|
853
945
|
console.log(`Next action (run existing): ${plan.nextActions.runRecommendedTests || plan.nextActions.runSmokeSuite}`);
|
|
@@ -856,7 +948,8 @@ async function main() {
|
|
|
856
948
|
// eslint-disable-next-line no-console
|
|
857
949
|
console.log(`Next action (heal): ${plan.nextActions.healGeneratedTests}`);
|
|
858
950
|
}
|
|
859
|
-
|
|
951
|
+
const failOnLegacyFlag = args.failOnMustAddTests && plan.decision.action === 'must-add-tests';
|
|
952
|
+
if (failOnLegacyFlag || plan.enforcement.shouldFail) {
|
|
860
953
|
process.exit(2);
|
|
861
954
|
}
|
|
862
955
|
return;
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
2
|
+
// See LICENSE.txt for license information.
|
|
3
|
+
import { existsSync, readFileSync } from 'fs';
|
|
4
|
+
import { isAbsolute, join } from 'path';
|
|
5
|
+
import { LLMProviderFactory } from '../provider_factory.js';
|
|
6
|
+
import { normalizePath, tokenize, uniqueTokens } from './utils.js';
|
|
7
|
+
function extractJson(text) {
|
|
8
|
+
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
9
|
+
const candidates = fenced ? [fenced[1], text] : [text];
|
|
10
|
+
for (const candidate of candidates) {
|
|
11
|
+
const start = candidate.indexOf('{');
|
|
12
|
+
const end = candidate.lastIndexOf('}');
|
|
13
|
+
if (start < 0 || end <= start) {
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
const raw = candidate.slice(start, end + 1);
|
|
17
|
+
try {
|
|
18
|
+
const parsed = JSON.parse(raw);
|
|
19
|
+
if (parsed && Array.isArray(parsed.flows)) {
|
|
20
|
+
return parsed;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// Ignore parse failure and keep trying other candidates.
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
function resolveContextFiles(appRoot, testsRoot, files) {
|
|
30
|
+
const resolved = [];
|
|
31
|
+
const seen = new Set();
|
|
32
|
+
const maxCharsPerFile = 12000;
|
|
33
|
+
const maxTotalChars = 32000;
|
|
34
|
+
let totalChars = 0;
|
|
35
|
+
for (const file of files) {
|
|
36
|
+
const candidates = isAbsolute(file)
|
|
37
|
+
? [file]
|
|
38
|
+
: [join(testsRoot, file), join(appRoot, file)];
|
|
39
|
+
for (const candidate of candidates) {
|
|
40
|
+
const normalized = normalizePath(candidate);
|
|
41
|
+
if (seen.has(normalized) || !existsSync(candidate)) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const content = readFileSync(candidate, 'utf-8');
|
|
45
|
+
const trimmed = content.trim();
|
|
46
|
+
if (!trimmed) {
|
|
47
|
+
seen.add(normalized);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const remaining = Math.max(0, maxTotalChars - totalChars);
|
|
51
|
+
if (remaining <= 0) {
|
|
52
|
+
return resolved;
|
|
53
|
+
}
|
|
54
|
+
const clipped = trimmed.slice(0, Math.min(maxCharsPerFile, remaining));
|
|
55
|
+
resolved.push({ path: normalized, content: clipped });
|
|
56
|
+
seen.add(normalized);
|
|
57
|
+
totalChars += clipped.length;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return resolved;
|
|
62
|
+
}
|
|
63
|
+
function priorityFromEntry(entry) {
|
|
64
|
+
if (entry.priority === 'P0' || entry.priority === 'P1' || entry.priority === 'P2') {
|
|
65
|
+
return entry.priority;
|
|
66
|
+
}
|
|
67
|
+
const score = typeof entry.score === 'number' ? entry.score : 0;
|
|
68
|
+
if (score >= 8) {
|
|
69
|
+
return 'P0';
|
|
70
|
+
}
|
|
71
|
+
if (score >= 5) {
|
|
72
|
+
return 'P1';
|
|
73
|
+
}
|
|
74
|
+
return 'P2';
|
|
75
|
+
}
|
|
76
|
+
function normalizeFlowId(value) {
|
|
77
|
+
return normalizePath(value)
|
|
78
|
+
.replace(/[^a-zA-Z0-9/_-]+/g, '_')
|
|
79
|
+
.replace(/\/{2,}/g, '/')
|
|
80
|
+
.replace(/^\/+/, '')
|
|
81
|
+
.replace(/\/+$/, '')
|
|
82
|
+
.trim();
|
|
83
|
+
}
|
|
84
|
+
function sanitizeReasons(reasons, fallback) {
|
|
85
|
+
if (!Array.isArray(reasons)) {
|
|
86
|
+
return [fallback];
|
|
87
|
+
}
|
|
88
|
+
const cleaned = reasons.filter((entry) => typeof entry === 'string').map((entry) => entry.trim()).filter(Boolean);
|
|
89
|
+
return cleaned.length > 0 ? cleaned : [fallback];
|
|
90
|
+
}
|
|
91
|
+
function sanitizeKeywords(keywords, fallbackTokens) {
|
|
92
|
+
if (!Array.isArray(keywords)) {
|
|
93
|
+
return uniqueTokens(fallbackTokens).slice(0, 20);
|
|
94
|
+
}
|
|
95
|
+
const fromAI = keywords.filter((entry) => typeof entry === 'string').flatMap((entry) => tokenize(entry));
|
|
96
|
+
return uniqueTokens([...fromAI, ...fallbackTokens]).slice(0, 20);
|
|
97
|
+
}
|
|
98
|
+
function summarizeFiles(files, changedFileSet, maxFiles) {
|
|
99
|
+
const sorted = [...files].sort((a, b) => {
|
|
100
|
+
const aChanged = changedFileSet.has(a.relativePath) ? 1 : 0;
|
|
101
|
+
const bChanged = changedFileSet.has(b.relativePath) ? 1 : 0;
|
|
102
|
+
if (aChanged !== bChanged) {
|
|
103
|
+
return bChanged - aChanged;
|
|
104
|
+
}
|
|
105
|
+
const aSignals = (a.isUI ? 1 : 0) + (a.isScreen ? 1 : 0) + (a.isState ? 1 : 0) + (a.hasInteractions ? 1 : 0);
|
|
106
|
+
const bSignals = (b.isUI ? 1 : 0) + (b.isScreen ? 1 : 0) + (b.isState ? 1 : 0) + (b.hasInteractions ? 1 : 0);
|
|
107
|
+
if (aSignals !== bSignals) {
|
|
108
|
+
return bSignals - aSignals;
|
|
109
|
+
}
|
|
110
|
+
return a.relativePath.localeCompare(b.relativePath);
|
|
111
|
+
}).slice(0, Math.max(20, maxFiles));
|
|
112
|
+
return sorted.map((file) => ({
|
|
113
|
+
path: file.relativePath,
|
|
114
|
+
changed: changedFileSet.has(file.relativePath),
|
|
115
|
+
isUI: file.isUI,
|
|
116
|
+
isScreen: file.isScreen,
|
|
117
|
+
isComponent: file.isComponent,
|
|
118
|
+
isState: file.isState,
|
|
119
|
+
isStyle: file.isStyle,
|
|
120
|
+
hasInteractions: file.hasInteractions,
|
|
121
|
+
keywords: file.keywords.slice(0, 20),
|
|
122
|
+
audience: file.audience,
|
|
123
|
+
flags: file.flags?.map((flag) => ({ name: flag.name, source: flag.source, defaultState: flag.defaultState })),
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
function mergeFlow(existing, candidate) {
|
|
127
|
+
if (!existing) {
|
|
128
|
+
return candidate;
|
|
129
|
+
}
|
|
130
|
+
const priorityOrder = { P0: 0, P1: 1, P2: 2 };
|
|
131
|
+
const priority = priorityOrder[candidate.priority] < priorityOrder[existing.priority]
|
|
132
|
+
? candidate.priority
|
|
133
|
+
: existing.priority;
|
|
134
|
+
return {
|
|
135
|
+
...existing,
|
|
136
|
+
name: existing.name || candidate.name,
|
|
137
|
+
kind: existing.kind || candidate.kind,
|
|
138
|
+
score: Math.max(existing.score, candidate.score),
|
|
139
|
+
priority,
|
|
140
|
+
reasons: uniqueTokens([...(existing.reasons || []), ...(candidate.reasons || [])]),
|
|
141
|
+
keywords: uniqueTokens([...(existing.keywords || []), ...(candidate.keywords || [])]),
|
|
142
|
+
files: uniqueTokens([...(existing.files || []), ...(candidate.files || [])]),
|
|
143
|
+
audience: uniqueTokens([...(existing.audience || []), ...(candidate.audience || [])]),
|
|
144
|
+
flags: [...(existing.flags || []), ...(candidate.flags || [])],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
export async function mapAIFlowsFromFiles(appRoot, testsRoot, config, files, changedFiles) {
|
|
148
|
+
const providerName = config.provider === 'auto' ? 'auto' : config.provider;
|
|
149
|
+
const warnings = [];
|
|
150
|
+
if (!config.enabled) {
|
|
151
|
+
return {
|
|
152
|
+
enabled: false,
|
|
153
|
+
used: false,
|
|
154
|
+
provider: providerName,
|
|
155
|
+
flowCount: 0,
|
|
156
|
+
warnings,
|
|
157
|
+
flows: [],
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
if (files.length === 0) {
|
|
161
|
+
warnings.push('AI flow analysis skipped: no analyzable files were found.');
|
|
162
|
+
return {
|
|
163
|
+
enabled: true,
|
|
164
|
+
used: false,
|
|
165
|
+
provider: providerName,
|
|
166
|
+
flowCount: 0,
|
|
167
|
+
warnings,
|
|
168
|
+
flows: [],
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
const changedFileSet = new Set(changedFiles.map((entry) => normalizePath(entry)));
|
|
172
|
+
const summarizedFiles = summarizeFiles(files, changedFileSet, config.maxFilesPerRequest);
|
|
173
|
+
const allowedFiles = new Set(files.map((entry) => entry.relativePath));
|
|
174
|
+
const fileByPath = new Map(files.map((entry) => [entry.relativePath, entry]));
|
|
175
|
+
const contextFiles = resolveContextFiles(appRoot, testsRoot, config.contextFiles || []);
|
|
176
|
+
const contextBlock = contextFiles.length > 0
|
|
177
|
+
? contextFiles.map((entry) => `### Context: ${entry.path}\n${entry.content}`).join('\n\n')
|
|
178
|
+
: 'No optional markdown context files were found.';
|
|
179
|
+
if (contextFiles.length === 0) {
|
|
180
|
+
warnings.push('AI flow analysis context files were not found; continuing without optional markdown context.');
|
|
181
|
+
}
|
|
182
|
+
let provider;
|
|
183
|
+
try {
|
|
184
|
+
provider = config.provider === 'auto'
|
|
185
|
+
? await LLMProviderFactory.createFromEnv()
|
|
186
|
+
: LLMProviderFactory.createFromString(config.provider);
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
190
|
+
warnings.push(`AI flow analysis unavailable (${providerName}): ${message}`);
|
|
191
|
+
return {
|
|
192
|
+
enabled: true,
|
|
193
|
+
used: false,
|
|
194
|
+
provider: providerName,
|
|
195
|
+
flowCount: 0,
|
|
196
|
+
warnings,
|
|
197
|
+
flows: [],
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
const prompt = [
|
|
201
|
+
'You are an expert frontend impact analyst for Mattermost.',
|
|
202
|
+
'Build impacted user flows from changed frontend files.',
|
|
203
|
+
'This must be flow-centric, not file-centric.',
|
|
204
|
+
'',
|
|
205
|
+
'Return strict JSON only with this exact shape:',
|
|
206
|
+
'{"flows":[{"id":"<flow_id>","name":"<name>","kind":"flow|screen","priority":"P0|P1|P2","score":10,"reasons":["..."],"keywords":["..."],"files":["relative/path.tsx"]}]}',
|
|
207
|
+
'',
|
|
208
|
+
'Rules:',
|
|
209
|
+
'- Use only file paths listed in FILES.',
|
|
210
|
+
'- Every flow must have at least one file.',
|
|
211
|
+
'- Keep IDs stable and lowercase with underscores when possible.',
|
|
212
|
+
'- Prioritize true user-impacting flows; avoid low-value internal buckets.',
|
|
213
|
+
'- Keep at most 6 file paths per flow.',
|
|
214
|
+
`- Keep at most ${Math.max(1, config.maxFlowsPerRequest)} flows.`,
|
|
215
|
+
'',
|
|
216
|
+
`CHANGED_FILES (${changedFileSet.size}):`,
|
|
217
|
+
JSON.stringify(Array.from(changedFileSet), null, 2),
|
|
218
|
+
'',
|
|
219
|
+
`FILES (${summarizedFiles.length}):`,
|
|
220
|
+
JSON.stringify(summarizedFiles, null, 2),
|
|
221
|
+
'',
|
|
222
|
+
contextBlock,
|
|
223
|
+
].join('\n');
|
|
224
|
+
let parsed = null;
|
|
225
|
+
try {
|
|
226
|
+
const response = await provider.generateText(prompt, {
|
|
227
|
+
maxTokens: Math.max(800, config.maxTokens),
|
|
228
|
+
temperature: Math.max(0, Math.min(1, config.temperature)),
|
|
229
|
+
systemPrompt: 'Return only valid JSON. Do not include markdown fences unless necessary.',
|
|
230
|
+
});
|
|
231
|
+
parsed = extractJson(response.text);
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
235
|
+
warnings.push(`AI flow analysis request failed (${provider.name}): ${message}`);
|
|
236
|
+
return {
|
|
237
|
+
enabled: true,
|
|
238
|
+
used: false,
|
|
239
|
+
provider: provider.name,
|
|
240
|
+
flowCount: 0,
|
|
241
|
+
warnings,
|
|
242
|
+
flows: [],
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
if (!parsed) {
|
|
246
|
+
warnings.push(`AI flow analysis returned invalid JSON (${provider.name}).`);
|
|
247
|
+
return {
|
|
248
|
+
enabled: true,
|
|
249
|
+
used: false,
|
|
250
|
+
provider: provider.name,
|
|
251
|
+
flowCount: 0,
|
|
252
|
+
warnings,
|
|
253
|
+
flows: [],
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
const flowsById = new Map();
|
|
257
|
+
for (const entry of parsed.flows) {
|
|
258
|
+
if (!entry || !Array.isArray(entry.files)) {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const validFiles = Array.from(new Set(entry.files
|
|
262
|
+
.filter((value) => typeof value === 'string')
|
|
263
|
+
.map((value) => normalizePath(value))
|
|
264
|
+
.filter((value) => allowedFiles.has(value)))).slice(0, 6);
|
|
265
|
+
if (validFiles.length === 0) {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const rawId = typeof entry.id === 'string' && entry.id.trim()
|
|
269
|
+
? entry.id
|
|
270
|
+
: (typeof entry.name === 'string' && entry.name.trim() ? entry.name : validFiles[0]);
|
|
271
|
+
const id = normalizeFlowId(rawId);
|
|
272
|
+
if (!id) {
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const fallbackTokens = uniqueTokens([
|
|
276
|
+
...tokenize(id),
|
|
277
|
+
...(typeof entry.name === 'string' ? tokenize(entry.name) : []),
|
|
278
|
+
...validFiles.flatMap((value) => tokenize(value)),
|
|
279
|
+
]);
|
|
280
|
+
const linkedFiles = validFiles.map((path) => fileByPath.get(path)).filter(Boolean);
|
|
281
|
+
const audience = uniqueTokens(linkedFiles.flatMap((file) => file.audience || []));
|
|
282
|
+
const flags = linkedFiles.flatMap((file) => file.flags || []);
|
|
283
|
+
const score = typeof entry.score === 'number' && Number.isFinite(entry.score)
|
|
284
|
+
? Math.max(1, Math.min(20, Math.round(entry.score)))
|
|
285
|
+
: Math.max(4, validFiles.length * 2);
|
|
286
|
+
const flow = {
|
|
287
|
+
id,
|
|
288
|
+
name: typeof entry.name === 'string' && entry.name.trim() ? entry.name.trim() : id.replace(/[_/.-]+/g, ' '),
|
|
289
|
+
kind: entry.kind === 'screen' ? 'screen' : 'flow',
|
|
290
|
+
score,
|
|
291
|
+
priority: priorityFromEntry(entry),
|
|
292
|
+
reasons: sanitizeReasons(entry.reasons, 'AI flow analysis identified impacted behavior'),
|
|
293
|
+
keywords: sanitizeKeywords(entry.keywords, fallbackTokens),
|
|
294
|
+
files: validFiles,
|
|
295
|
+
audience,
|
|
296
|
+
flags,
|
|
297
|
+
};
|
|
298
|
+
flowsById.set(id, mergeFlow(flowsById.get(id), flow));
|
|
299
|
+
if (flowsById.size >= Math.max(1, config.maxFlowsPerRequest)) {
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const flows = Array.from(flowsById.values());
|
|
304
|
+
if (flows.length === 0) {
|
|
305
|
+
warnings.push('AI flow analysis did not return any valid flows linked to changed files.');
|
|
306
|
+
return {
|
|
307
|
+
enabled: true,
|
|
308
|
+
used: false,
|
|
309
|
+
provider: provider.name,
|
|
310
|
+
flowCount: 0,
|
|
311
|
+
warnings,
|
|
312
|
+
flows: [],
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
enabled: true,
|
|
317
|
+
used: true,
|
|
318
|
+
provider: provider.name,
|
|
319
|
+
flowCount: flows.length,
|
|
320
|
+
warnings,
|
|
321
|
+
flows,
|
|
322
|
+
};
|
|
323
|
+
}
|