@skyramp/mcp 0.3.0-rc.1 → 0.3.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.
Files changed (136) hide show
  1. package/build/prompts/test-maintenance/actionsInstructions.js +4 -2
  2. package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +9 -0
  3. package/build/prompts/test-maintenance/drift-analysis-prompt.js +8 -7
  4. package/build/prompts/test-maintenance/driftAnalysisSections.js +4 -5
  5. package/build/prompts/test-maintenance/driftAnalysisShared.d.ts +11 -0
  6. package/build/prompts/test-maintenance/driftAnalysisShared.js +22 -6
  7. package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +23 -31
  8. package/build/prompts/test-recommendation/diffExecutionPlan.d.ts +7 -1
  9. package/build/prompts/test-recommendation/diffExecutionPlan.js +20 -6
  10. package/build/prompts/test-recommendation/scopeAssessment.d.ts +10 -1
  11. package/build/prompts/test-recommendation/scopeAssessment.js +27 -2
  12. package/build/prompts/test-recommendation/scopeAssessment.test.js +35 -0
  13. package/build/prompts/test-recommendation/test-recommendation-prompt.js +5 -1
  14. package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +61 -0
  15. package/build/prompts/testbot/testbot-prompts.js +7 -5
  16. package/build/prompts/testbot/testbot-prompts.test.js +1 -1
  17. package/build/services/AnalyticsService.js +37 -2
  18. package/build/services/TestDiscoveryService.d.ts +38 -4
  19. package/build/services/TestDiscoveryService.js +187 -16
  20. package/build/services/TestDiscoveryService.test.js +232 -10
  21. package/build/services/TestExecutionService.d.ts +12 -1
  22. package/build/services/TestExecutionService.js +20 -41
  23. package/build/services/TestGenerationService.d.ts +8 -0
  24. package/build/services/TestGenerationService.js +62 -17
  25. package/build/services/TestGenerationService.test.js +67 -1
  26. package/build/tools/auth/loginTool.js +1 -1
  27. package/build/tools/auth/logoutTool.js +1 -1
  28. package/build/tools/code-refactor/codeReuseTool.js +1 -1
  29. package/build/tools/code-refactor/modularizationTool.js +1 -1
  30. package/build/tools/executeSkyrampTestTool.js +30 -1
  31. package/build/tools/fixErrorTool.js +1 -1
  32. package/build/tools/one-click/oneClickTool.js +1 -1
  33. package/build/tools/test-management/actionsTool.js +76 -41
  34. package/build/tools/test-management/actionsTool.test.js +104 -38
  35. package/build/tools/test-management/analyzeChangesTool.js +102 -4
  36. package/build/tools/test-management/analyzeChangesTool.test.js +3 -1
  37. package/build/tools/test-management/analyzeTestHealthTool.js +9 -1
  38. package/build/tools/test-management/analyzeTestHealthTool.test.js +1 -1
  39. package/build/tools/trace/startTraceCollectionTool.js +1 -1
  40. package/build/tools/trace/stopTraceCollectionTool.js +1 -1
  41. package/build/tools/workspace/initScanWorkspaceTool.js +1 -1
  42. package/build/tools/workspace/initializeWorkspaceTool.js +1 -1
  43. package/build/tools/workspace/initializeWorkspaceTool.test.js +1 -1
  44. package/build/types/RepositoryAnalysis.d.ts +6 -6
  45. package/build/types/TestAnalysis.d.ts +13 -0
  46. package/build/utils/dartRouteExtractor.js +8 -30
  47. package/build/utils/docker.test.js +1 -1
  48. package/build/utils/fileWalk.d.ts +71 -0
  49. package/build/utils/fileWalk.js +79 -0
  50. package/build/utils/fileWalk.test.d.ts +1 -0
  51. package/build/utils/fileWalk.test.js +252 -0
  52. package/build/utils/frontendIntegration.js +20 -36
  53. package/build/utils/frontendSelectors.d.ts +34 -0
  54. package/build/utils/frontendSelectors.js +124 -0
  55. package/build/utils/frontendSelectors.test.d.ts +1 -0
  56. package/build/utils/frontendSelectors.test.js +118 -0
  57. package/build/utils/importerHop.d.ts +18 -0
  58. package/build/utils/importerHop.js +48 -3
  59. package/build/utils/importerHop.test.js +71 -1
  60. package/build/utils/pythonMountPrefixes.d.ts +25 -0
  61. package/build/utils/pythonMountPrefixes.js +347 -0
  62. package/build/utils/pythonMountPrefixes.test.d.ts +1 -0
  63. package/build/utils/pythonMountPrefixes.test.js +113 -0
  64. package/build/utils/repoScanner.js +30 -23
  65. package/build/utils/repoScanner.test.js +98 -0
  66. package/build/utils/routeParsers.d.ts +7 -1
  67. package/build/utils/routeParsers.js +32 -18
  68. package/build/utils/sourceRouteExtractor.js +17 -40
  69. package/build/utils/trace-parser.js +7 -19
  70. package/build/utils/versions.d.ts +3 -3
  71. package/build/utils/versions.js +1 -1
  72. package/build/utils/walkerCharacterization.test.d.ts +1 -0
  73. package/build/utils/walkerCharacterization.test.js +233 -0
  74. package/node_modules/playwright/lib/common/config.js +1 -1
  75. package/node_modules/playwright/lib/common/configLoader.js +1 -1
  76. package/node_modules/playwright/lib/common/fixtures.js +1 -1
  77. package/node_modules/playwright/lib/common/testType.js +1 -1
  78. package/node_modules/playwright/lib/index.js +2 -2
  79. package/node_modules/playwright/lib/isomorphic/testTree.js +1 -1
  80. package/node_modules/playwright/lib/matchers/expect.js +1 -1
  81. package/node_modules/playwright/lib/mcp/browser/tab.js +1 -1
  82. package/node_modules/playwright/lib/mcp/browser/tools/navigate.js +1 -1
  83. package/node_modules/playwright/lib/mcp/browser/tools/tracing.js +1 -1
  84. package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +7 -0
  85. package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +2 -1
  86. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +3 -1
  87. package/node_modules/playwright/lib/reporters/base.js +2 -2
  88. package/node_modules/playwright/lib/reporters/list.js +1 -1
  89. package/node_modules/playwright/lib/reporters/teleEmitter.js +1 -1
  90. package/node_modules/playwright/lib/worker/testInfo.js +2 -2
  91. package/node_modules/playwright/node_modules/playwright-core/ThirdPartyNotices.txt +23 -126
  92. package/node_modules/playwright/node_modules/playwright-core/lib/generated/injectedScriptSource.js +1 -1
  93. package/node_modules/playwright/node_modules/playwright-core/lib/generated/pollingRecorderSource.js +1 -1
  94. package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +2 -1
  95. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-Cqp3cwEJ.js → codeMirrorModule-aszq5EdG.js} +1 -1
  96. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{defaultSettingsView-CYf9adZh.js → defaultSettingsView-BxS7Jm4s.js} +94 -94
  97. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.d4gkVSou.js → index.D4JTTy4R.js} +1 -1
  98. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
  99. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{uiMode.u4_8VnCV.js → uiMode.DaRMQKOI.js} +1 -1
  100. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
  101. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  102. package/node_modules/playwright/node_modules/playwright-core/src/generated/injectedScriptSource.ts +1 -1
  103. package/node_modules/playwright/node_modules/playwright-core/src/generated/pollingRecorderSource.ts +1 -1
  104. package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
  105. package/node_modules/playwright/package.json +1 -1
  106. package/package.json +3 -3
  107. package/node_modules/playwright/._ThirdPartyNotices.txt +0 -0
  108. package/node_modules/playwright/._cli.js +0 -0
  109. package/node_modules/playwright/._index.d.ts +0 -0
  110. package/node_modules/playwright/._index.js +0 -0
  111. package/node_modules/playwright/._index.mjs +0 -0
  112. package/node_modules/playwright/._jsx-runtime.js +0 -0
  113. package/node_modules/playwright/._jsx-runtime.mjs +0 -0
  114. package/node_modules/playwright/._lib +0 -0
  115. package/node_modules/playwright/._node_modules +0 -0
  116. package/node_modules/playwright/._package.json +0 -0
  117. package/node_modules/playwright/._test.d.ts +0 -0
  118. package/node_modules/playwright/._test.js +0 -0
  119. package/node_modules/playwright/._test.mjs +0 -0
  120. package/node_modules/playwright/._types +0 -0
  121. package/node_modules/playwright/._vitest.config.ts +0 -0
  122. package/node_modules/playwright/node_modules/playwright-core/._.DS_Store +0 -0
  123. package/node_modules/playwright/node_modules/playwright-core/._.npmignore +0 -0
  124. package/node_modules/playwright/node_modules/playwright-core/._README.md +0 -0
  125. package/node_modules/playwright/node_modules/playwright-core/._ThirdPartyNotices.txt +0 -0
  126. package/node_modules/playwright/node_modules/playwright-core/._bin +0 -0
  127. package/node_modules/playwright/node_modules/playwright-core/._browsers.json +0 -0
  128. package/node_modules/playwright/node_modules/playwright-core/._bundles +0 -0
  129. package/node_modules/playwright/node_modules/playwright-core/._cli.js +0 -0
  130. package/node_modules/playwright/node_modules/playwright-core/._index.d.ts +0 -0
  131. package/node_modules/playwright/node_modules/playwright-core/._index.js +0 -0
  132. package/node_modules/playwright/node_modules/playwright-core/._index.mjs +0 -0
  133. package/node_modules/playwright/node_modules/playwright-core/._lib +0 -0
  134. package/node_modules/playwright/node_modules/playwright-core/._package.json +0 -0
  135. package/node_modules/playwright/node_modules/playwright-core/._src +0 -0
  136. package/node_modules/playwright/node_modules/playwright-core/._types +0 -0
@@ -326,6 +326,34 @@ describe("TestDiscoveryService", () => {
326
326
  // Products file should NOT be in relevantExternalTestPaths
327
327
  expect(result.relevantExternalTestPaths.some(p => p.includes("test_products_api"))).toBe(false);
328
328
  });
329
+ it("includes a file matched only by changedSymbols content grep (SKYR-3924 schema-only)", async () => {
330
+ // Path/URL do not token-match the resource; the file is relevant only because it
331
+ // references the changed symbol by name — the schema/DTO-only diff case.
332
+ writeFile("test_schema.py", "from app.schemas import DeploymentCreate\ndef test_ctor():\n DeploymentCreate(name='x')");
333
+ writeFile("test_unrelated.py", "def test_noop(): pass");
334
+ const result = await service.discoverTests(tmpDir, {
335
+ changedResources: ["workpool"],
336
+ changedSymbols: ["DeploymentCreate"],
337
+ });
338
+ expect(result.relevantExternalTestPaths.some((p) => p.includes("test_schema"))).toBe(true);
339
+ expect(result.relevantExternalTestPaths.some((p) => p.includes("test_unrelated"))).toBe(false);
340
+ });
341
+ it("keeps direct endpoint-call matches uncapped when preciseResources is set", async () => {
342
+ // MAX_CONTENT_PROMOTED = 5; create 7 files that each POST the changed endpoint but whose
343
+ // names do not token-match the resource, so they route through endpoint-call matching.
344
+ for (let i = 0; i < 7; i++) {
345
+ writeFile(`test_case_${i}.py`, 'import requests\nrequests.post("/api/deployments/")');
346
+ }
347
+ const precise = await service.discoverTests(tmpDir, {
348
+ changedResources: ["deployments"],
349
+ preciseResources: true,
350
+ });
351
+ expect(precise.relevantExternalTestPaths.length).toBe(7);
352
+ // Without preciseResources the strong endpoint-call matches are capped together with
353
+ // loose mentions at MAX_CONTENT_PROMOTED.
354
+ const capped = await service.discoverTests(tmpDir, { changedResources: ["deployments"] });
355
+ expect(capped.relevantExternalTestPaths.length).toBe(5);
356
+ });
329
357
  it("does full extraction for all files when no changedResources provided", async () => {
330
358
  writeFile("test_orders_api.py", 'import requests\nrequests.get("/api/orders")');
331
359
  const result = await service.discoverTests(tmpDir);
@@ -437,13 +465,13 @@ describe("TestDiscoveryService — detectExternalFramework ordering", () => {
437
465
  });
438
466
  it("RTL file with it()/expect() → rtl (not jest)", async () => {
439
467
  writeFile("src/Cart.test.tsx", "import { render } from '@testing-library/react';\nit('renders', () => { expect(true).toBe(true); });");
440
- const result = await service.discoverTests(tmpDir, { changedResources: ["cart"], hasFrontendChanges: true });
468
+ const result = await service.discoverTests(tmpDir, { changedResources: ["cart"], changedFrontendFiles: ["src/Cart.tsx"] });
441
469
  const t = result.tests.find(f => f.testFile.includes("Cart.test.tsx"));
442
470
  expect(t?.framework).toBe("rtl");
443
471
  });
444
472
  it("vue-test-utils file → vue-test-utils (not jest)", async () => {
445
473
  writeFile("src/Counter.test.ts", "import { mount } from '@testing-library/vue';\nit('works', () => { expect(true).toBe(true); });");
446
- const result = await service.discoverTests(tmpDir, { changedResources: ["counter"], hasFrontendChanges: true });
474
+ const result = await service.discoverTests(tmpDir, { changedResources: ["counter"], changedFrontendFiles: ["src/Counter.tsx"] });
447
475
  const t = result.tests.find(f => f.testFile.includes("Counter.test.ts"));
448
476
  expect(t?.framework).toBe("vue-test-utils");
449
477
  });
@@ -593,9 +621,9 @@ describe("TestDiscoveryService — scoreRelevanceByContent regex safety", () =>
593
621
  });
594
622
  });
595
623
  // ---------------------------------------------------------------------------
596
- // hasFrontendChanges=false: no .e2e.ts promotion
624
+ // UI promotion gating: no changed frontend files ⇒ no .e2e.ts promotion
597
625
  // ---------------------------------------------------------------------------
598
- describe("TestDiscoveryService — hasFrontendChanges=false guard", () => {
626
+ describe("TestDiscoveryService — UI promotion gating (changedFrontendFiles)", () => {
599
627
  let service;
600
628
  let tmpDir;
601
629
  beforeEach(() => {
@@ -609,17 +637,17 @@ describe("TestDiscoveryService — hasFrontendChanges=false guard", () => {
609
637
  fs.writeFileSync(fp, content, "utf-8");
610
638
  return fp;
611
639
  }
612
- it("does not promote .e2e.ts files when hasFrontendChanges=false", async () => {
640
+ it("does not promote .e2e.ts files when no changed frontend files are supplied", async () => {
613
641
  writeFile("apps/web/playwright/onboarding.e2e.ts", "import { test } from '@playwright/test';\ntest('onboarding', async ({ page }) => { await page.goto('/onboarding'); });");
614
- // changedResources=[] (no API endpoints) + hasFrontendChanges=false (config-only PR)
615
- const result = await service.discoverTests(tmpDir, { changedResources: [], hasFrontendChanges: false });
642
+ // changedResources=[] (no API endpoints) + no changedFrontendFiles (config-only PR) ⇒ Step 2 skipped
643
+ const result = await service.discoverTests(tmpDir, { changedResources: [] });
616
644
  expect(result.relevantExternalTestPaths.length).toBe(0);
617
645
  expect(result.tests.length).toBe(0);
618
646
  });
619
- it("promotes .e2e.ts files when hasFrontendChanges=true", async () => {
647
+ it("promotes .e2e.ts files relevant to a changed frontend file", async () => {
620
648
  writeFile("apps/web/playwright/onboarding.e2e.ts", "import { test } from '@playwright/test';\ntest('onboarding', async ({ page }) => { await page.goto('/onboarding'); });");
621
- const result = await service.discoverTests(tmpDir, { changedResources: [], hasFrontendChanges: true });
622
- // .e2e.ts should now be promoted (UI_TEST_EXT includes .e2e.ts)
649
+ const result = await service.discoverTests(tmpDir, { changedResources: [], changedFrontendFiles: ["apps/web/onboarding.tsx"] });
650
+ // .e2e.ts should be promoted (UI_TEST_EXT includes .e2e.ts; "onboarding" token matches)
623
651
  expect(result.tests.some(t => t.testFile.includes("onboarding.e2e.ts"))).toBe(true);
624
652
  });
625
653
  // ── Step 3: API test promotion ─────────────────────────────────────────────
@@ -759,3 +787,197 @@ class TestCronSchedule:
759
787
  });
760
788
  });
761
789
  });
790
+ // ---------------------------------------------------------------------------
791
+ // SKYR-3941: UI-test promotion (Step 2) is scoped by relevance to the changed
792
+ // frontend files — a frontend PR must not pull in the whole monorepo's UI suite.
793
+ // ---------------------------------------------------------------------------
794
+ describe("TestDiscoveryService — UI promotion scoping (SKYR-3941)", () => {
795
+ let service;
796
+ let tmpDir;
797
+ // Minimal RTL content so the file classifies as an external UI test.
798
+ const RTL = "import { render } from '@testing-library/react';\nit('renders', () => { expect(true).toBe(true); });";
799
+ beforeEach(() => {
800
+ service = new TestDiscoveryService();
801
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tds-fe-"));
802
+ });
803
+ afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); });
804
+ function writeFile(rel, content) {
805
+ const fp = path.join(tmpDir, rel);
806
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
807
+ fs.writeFileSync(fp, content, "utf-8");
808
+ return fp;
809
+ }
810
+ it("promotes UI tests relevant to the changed frontend files, not the whole suite", async () => {
811
+ writeFile("src/routes/orders/OrderSearch.test.tsx", RTL);
812
+ writeFile("packages/design-system/icons/__tests__/arrow-small-down.spec.tsx", RTL);
813
+ // No API resources (Step 1 promotes nothing) — promotion is purely Step 2.
814
+ const result = await service.discoverTests(tmpDir, {
815
+ changedResources: [], changedFrontendFiles: ["src/routes/orders/OrderSearch.tsx"],
816
+ });
817
+ const promoted = result.relevantExternalTestPaths;
818
+ expect(promoted.some(p => p.includes("OrderSearch.test.tsx"))).toBe(true);
819
+ expect(promoted.some(p => p.includes("arrow-small-down.spec.tsx"))).toBe(false);
820
+ });
821
+ it("matches a camelCase changed component against a hyphenated test name", async () => {
822
+ writeFile("src/order-search.spec.tsx", RTL);
823
+ const result = await service.discoverTests(tmpDir, {
824
+ changedResources: [], changedFrontendFiles: ["src/OrderSearch.tsx"],
825
+ });
826
+ expect(result.relevantExternalTestPaths.some(p => p.includes("order-search.spec.tsx"))).toBe(true);
827
+ });
828
+ it("uses the parent directory as the signal when the changed file's basename is generic (index.tsx)", async () => {
829
+ writeFile("src/orders/OrdersTable.test.tsx", RTL);
830
+ writeFile("src/billing/BillingPanel.test.tsx", RTL);
831
+ const result = await service.discoverTests(tmpDir, {
832
+ changedResources: [], changedFrontendFiles: ["src/orders/index.tsx"], // stem "index" generic → parent "orders" is the token
833
+ });
834
+ const promoted = result.relevantExternalTestPaths;
835
+ expect(promoted.some(p => p.includes("OrdersTable.test.tsx"))).toBe(true);
836
+ expect(promoted.some(p => p.includes("BillingPanel.test.tsx"))).toBe(false);
837
+ });
838
+ it("caps scoped promotion at the backstop even when many tests match", async () => {
839
+ for (let i = 0; i < 55; i++)
840
+ writeFile(`src/widgets/widget-${i}.test.tsx`, RTL);
841
+ const result = await service.discoverTests(tmpDir, {
842
+ changedResources: [], changedFrontendFiles: ["src/widgets/widget.tsx"], // token "widget" matches all 55
843
+ });
844
+ const promotedWidgets = result.relevantExternalTestPaths.filter(p => p.includes("widget-"));
845
+ expect(promotedWidgets.length).toBe(50); // MAX_UI_PROMOTED backstop
846
+ });
847
+ it("promotes a UI test that imports a changed component even when its name shares no token (import-edge recall)", async () => {
848
+ // "Checkout" shares no token with "PriceLabel" — only the import edge connects them.
849
+ writeFile("src/features/Checkout.test.tsx", "import { render } from '@testing-library/react';\nimport { PriceLabel } from '../components/PriceLabel';\nit('shows price', () => { render(<PriceLabel />); });");
850
+ writeFile("src/features/Billing.test.tsx", RTL); // no relevant import, no token overlap
851
+ const result = await service.discoverTests(tmpDir, {
852
+ changedResources: [], changedFrontendFiles: ["src/components/PriceLabel.tsx"],
853
+ });
854
+ const promoted = result.relevantExternalTestPaths;
855
+ expect(promoted.some(p => p.includes("Checkout.test.tsx"))).toBe(true); // via import edge
856
+ expect(promoted.some(p => p.includes("Billing.test.tsx"))).toBe(false);
857
+ });
858
+ it("does not let a generic-basename change (index.tsx) leak via import edge, but still matches on the parent dir", async () => {
859
+ writeFile("src/orders/OrdersList.test.tsx", RTL); // matches parent token "orders" (pass 1)
860
+ writeFile("src/widgets/Widget.test.tsx", "import { render } from '@testing-library/react';\nimport helpers from '../orders/index';\nit('x', () => {});"); // imports index — must NOT leak
861
+ const result = await service.discoverTests(tmpDir, {
862
+ changedResources: [], changedFrontendFiles: ["src/orders/index.tsx"],
863
+ });
864
+ const promoted = result.relevantExternalTestPaths;
865
+ expect(promoted.some(p => p.includes("OrdersList.test.tsx"))).toBe(true); // parent-dir token
866
+ expect(promoted.some(p => p.includes("Widget.test.tsx"))).toBe(false); // "/index'" signature skipped
867
+ });
868
+ it("falls back to capped promote-all when only generic entrypoints changed — never degenerates to zero (SKYR-3941)", async () => {
869
+ // src/app/index.tsx: stem "index" and parent "app" are both generic → no tokens, and
870
+ // "index" is skipped for import signatures → no signal at all. A root/entry change is
871
+ // broadly impactful, so promotion must fall back rather than promote nothing.
872
+ writeFile("src/routes/orders/OrderList.test.tsx", RTL);
873
+ writeFile("src/products/ProductList.test.tsx", RTL);
874
+ const result = await service.discoverTests(tmpDir, {
875
+ changedResources: [], changedFrontendFiles: ["src/app/index.tsx"],
876
+ });
877
+ const promoted = result.relevantExternalTestPaths;
878
+ // Neither test shares a token with "index"/"app" — without the fallback both would be
879
+ // dropped (score 0). The no-signal fallback promotes them instead.
880
+ expect(promoted.some(p => p.includes("OrderList.test.tsx"))).toBe(true);
881
+ expect(promoted.some(p => p.includes("ProductList.test.tsx"))).toBe(true);
882
+ });
883
+ });
884
+ // ---------------------------------------------------------------------------
885
+ // Pass 3 — selector-edge promotion: page objects/specs couple to a changed
886
+ // component by SELECTOR (data-testid / CSS class), not by filename or import.
887
+ // ---------------------------------------------------------------------------
888
+ describe("TestDiscoveryService — selector-edge promotion (changedSelectors)", () => {
889
+ let service;
890
+ let tmpDir;
891
+ beforeEach(() => {
892
+ service = new TestDiscoveryService();
893
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tds-sel-"));
894
+ });
895
+ afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); });
896
+ function writeFile(rel, content) {
897
+ const fp = path.join(tmpDir, rel);
898
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
899
+ fs.writeFileSync(fp, content, "utf-8");
900
+ return fp;
901
+ }
902
+ // A Playwright page object that selects a component by CSS class. It is NOT a
903
+ // *.test/*.spec file (so passes 1-2 never consider it) and shares no name token
904
+ // with the changed component, nor does it import it — the exact POM shape that
905
+ // regressed after SKYR-3941. It still holds the OLD (renamed-away) selector.
906
+ const POM_CONTENT = `\
907
+ import { Locator, Page } from '@playwright/test';
908
+
909
+ export class TableComponent {
910
+ readonly columnFilter: Locator;
911
+ constructor(private page: Page) {
912
+ this.columnFilter = page.locator('.t--table-filter-columns-dropdown');
913
+ }
914
+ async openFilter() { await this.columnFilter.click(); }
915
+ }
916
+ `;
917
+ it("promotes a page object that references a changed selector (not name/import matched)", async () => {
918
+ writeFile("app/client/playwright/page-objects/components/table.component.ts", POM_CONTENT);
919
+ const result = await service.discoverTests(tmpDir, {
920
+ changedResources: [],
921
+ // Changed component shares no token with "table.component" and the POM does not import it.
922
+ changedFrontendFiles: ["app/client/src/widgets/TableWidgetV2/component/header/actions/filter/CascadeFields.tsx"],
923
+ // Both the removed (old) and added (new) selector values, as the diff would carry them.
924
+ changedSelectors: ["t--table-filter-columns-dropdown", "t--tbl-filter-column-select"],
925
+ });
926
+ expect(result.tests.some(t => t.testFile.includes("table.component.ts"))).toBe(true);
927
+ expect(result.relevantExternalTestPaths.some(p => p.includes("table.component.ts"))).toBe(true);
928
+ });
929
+ it("does NOT promote the same page object when changedSelectors is absent (the regression baseline)", async () => {
930
+ writeFile("app/client/playwright/page-objects/components/table.component.ts", POM_CONTENT);
931
+ const result = await service.discoverTests(tmpDir, {
932
+ changedResources: [],
933
+ changedFrontendFiles: ["app/client/src/widgets/TableWidgetV2/component/header/actions/filter/CascadeFields.tsx"],
934
+ // no changedSelectors → passes 1-2 only → name/import miss → POM dropped
935
+ });
936
+ expect(result.tests.some(t => t.testFile.includes("table.component.ts"))).toBe(false);
937
+ });
938
+ it("does not promote a page object that references no changed selector", async () => {
939
+ writeFile("app/client/playwright/page-objects/components/unrelated.component.ts", "import { Page } from '@playwright/test';\nexport class Unrelated { constructor(p: Page) { p.locator('.t--some-other-widget-header'); } }");
940
+ const result = await service.discoverTests(tmpDir, {
941
+ changedResources: [],
942
+ changedFrontendFiles: ["app/client/src/widgets/TableWidgetV2/component/header/actions/filter/CascadeFields.tsx"],
943
+ changedSelectors: ["t--table-filter-columns-dropdown", "t--tbl-filter-column-select"],
944
+ });
945
+ expect(result.tests.some(t => t.testFile.includes("unrelated.component.ts"))).toBe(false);
946
+ });
947
+ it("promotes via a changed data-testid value (BEM/testid coupling)", async () => {
948
+ writeFile("e2e/pages/confirmation-modal.page.ts", "import { Page } from '@playwright/test';\nexport class ConfirmationModal { constructor(p: Page) { this.confirm = p.getByTestId('confirmation-modal-confirm-button'); } }");
949
+ const result = await service.discoverTests(tmpDir, {
950
+ changedResources: [],
951
+ changedFrontendFiles: ["src/modules/ui/layout/modal/components/ConfirmationModal.tsx"],
952
+ changedSelectors: ["confirmation-modal-confirm-button", "confirmation-modal-primary-button"],
953
+ });
954
+ expect(result.tests.some(t => t.testFile.includes("confirmation-modal.page.ts"))).toBe(true);
955
+ });
956
+ it("promotes a page object via a dynamic BEM base prefix (shared-component modifier)", async () => {
957
+ // The changed component composes its class dynamically (`ads-button--${kind}`), so only
958
+ // the base prefix `ads-button--` is extractable; the POM's concrete modifier classes
959
+ // (ads-button--primary/secondary) share that prefix and must still be promoted (SKYR-3943).
960
+ writeFile("app/client/playwright/page-objects/ads-button.page.ts", "import { Page } from '@playwright/test';\nexport class AdsButton { constructor(p: Page) { this.primary = p.locator('.ads-button--primary'); this.secondary = p.locator('.ads-button--secondary'); } }");
961
+ const result = await service.discoverTests(tmpDir, {
962
+ changedResources: [],
963
+ changedFrontendFiles: ["app/client/packages/design-system/ads/src/Button/Button.tsx"],
964
+ changedSelectors: ["ads-button--"],
965
+ });
966
+ expect(result.tests.some(t => t.testFile.includes("ads-button.page.ts"))).toBe(true);
967
+ });
968
+ it("caps pass-3 selector promotion at MAX_UI_PROMOTED (no re-flood)", async () => {
969
+ // 60 page objects all reference the same changed selector; pass 1/2 promote none of them
970
+ // (names/imports don't match), so pass 3 must cap total promotion at MAX_UI_PROMOTED (50).
971
+ for (let i = 0; i < 60; i++) {
972
+ writeFile(`app/client/playwright/page-objects/pom-${i}.page.ts`, `import { Page } from '@playwright/test';\nexport class P${i} { constructor(p: Page) { p.locator('.t--shared-widget-hook'); } }`);
973
+ }
974
+ const result = await service.discoverTests(tmpDir, {
975
+ changedResources: [],
976
+ changedFrontendFiles: ["app/client/src/widgets/SharedWidget.tsx"],
977
+ changedSelectors: ["t--shared-widget-hook"],
978
+ });
979
+ const promotedPoms = result.tests.filter(t => t.testFile.includes("/pom-")).length;
980
+ expect(promotedPoms).toBeGreaterThan(0);
981
+ expect(promotedPoms).toBeLessThanOrEqual(50);
982
+ });
983
+ });
@@ -1,5 +1,5 @@
1
1
  import { TestExecutionResult, BatchExecutionResult, TestExecutionOptions, ProgressCallback } from "../types/TestExecution.js";
2
- export declare const EXECUTOR_DOCKER_IMAGE = "skyramp/executor:v1.3.33";
2
+ export declare const EXECUTOR_DOCKER_IMAGE = "skyramp/executor:v1.3.34";
3
3
  export declare const PLAYWRIGHT_CONFIG_FILES: string[];
4
4
  export declare const EXCLUDED_MOUNT_ITEMS: string[];
5
5
  export declare const MOUNT_NULL_ITEMS: string[];
@@ -12,6 +12,12 @@ export declare function isBrowserTest(testType: string): boolean;
12
12
  * based on the test file path
13
13
  */
14
14
  export declare function getVideoSubdir(testFile: string): string;
15
+ /**
16
+ * Recursively scan a per-execution video output directory for the recorded
17
+ * `video.webm`. Returns its host path, or undefined if none was produced.
18
+ * Best-effort: scan errors are logged and treated as "no video".
19
+ */
20
+ export declare function collectVideoPath(videoHostDir: string): string | undefined;
15
21
  /**
16
22
  * Detect session file paths referenced in test files
17
23
  * Looks for storageState patterns in TypeScript/JavaScript/Python/Java/C# test files
@@ -24,6 +30,11 @@ export declare function getVideoSubdir(testFile: string): string;
24
30
  * workspace path at runtime).
25
31
  */
26
32
  export declare function detectSessionFiles(testFilePath: string): string[];
33
+ /**
34
+ * Recursively find all files/directories matching names in excludedItems within a directory.
35
+ * Skips recursing into directories that are themselves excluded.
36
+ */
37
+ export declare function findExcludedPaths(dir: string, excludedItems: string[]): string[];
27
38
  export declare class TestExecutionService {
28
39
  private docker;
29
40
  private imageReady;
@@ -10,6 +10,7 @@ import { TestExecutionStatus, } from "../types/TestExecution.js";
10
10
  import { TestType } from "../types/TestTypes.js";
11
11
  import { buildContainerEnv } from "./containerEnv.js";
12
12
  import { SKYRAMP_IMAGE_VERSION } from "../utils/versions.js";
13
+ import { walkDir } from "../utils/fileWalk.js";
13
14
  const DEFAULT_TIMEOUT = 300000; // 5 minutes
14
15
  const MAX_CONCURRENT_EXECUTIONS = 5;
15
16
  export const EXECUTOR_DOCKER_IMAGE = `skyramp/executor:${SKYRAMP_IMAGE_VERSION}`;
@@ -122,24 +123,17 @@ addopts = --video on --output ${setup.videoContainerDir}
122
123
  * `video.webm`. Returns its host path, or undefined if none was produced.
123
124
  * Best-effort: scan errors are logged and treated as "no video".
124
125
  */
125
- function collectVideoPath(videoHostDir) {
126
+ export function collectVideoPath(videoHostDir) {
126
127
  if (!videoHostDir)
127
128
  return undefined;
128
- const findVideo = (dir) => {
129
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
130
- const fullPath = path.join(dir, entry.name);
131
- if (entry.isFile() && entry.name === "video.webm")
132
- return fullPath;
133
- if (entry.isDirectory()) {
134
- const found = findVideo(fullPath);
135
- if (found)
136
- return found;
129
+ try {
130
+ let videoPath;
131
+ for (const [entry, fullPath] of walkDir(videoHostDir)) {
132
+ if (entry.name === "video.webm") {
133
+ videoPath = fullPath; // first match wins
134
+ break;
137
135
  }
138
136
  }
139
- return undefined;
140
- };
141
- try {
142
- const videoPath = findVideo(videoHostDir);
143
137
  if (videoPath) {
144
138
  logger.info(`Video recording found: ${videoPath}`);
145
139
  }
@@ -331,24 +325,14 @@ export function detectSessionFiles(testFilePath) {
331
325
  * Recursively find all files/directories matching names in excludedItems within a directory.
332
326
  * Skips recursing into directories that are themselves excluded.
333
327
  */
334
- function findExcludedPaths(dir, excludedItems) {
328
+ export function findExcludedPaths(dir, excludedItems) {
335
329
  const results = [];
336
- let entries;
337
- try {
338
- entries = fs.readdirSync(dir, { withFileTypes: true });
339
- }
340
- catch {
341
- return results;
342
- }
343
- for (const entry of entries) {
344
- const fullPath = path.join(dir, entry.name);
330
+ const shouldSkipDir = (entry) => excludedItems.includes(entry.name) || EXCLUDED_MOUNT_ITEMS.includes(entry.name);
331
+ for (const [entry, fullPath] of walkDir(dir, { shouldSkipDir })) {
345
332
  // Only shadow files — mounting /dev/null to a directory target causes Docker errors
346
- if (entry.isFile() && excludedItems.includes(entry.name)) {
333
+ if (excludedItems.includes(entry.name)) {
347
334
  results.push(fullPath);
348
335
  }
349
- if (entry.isDirectory() && !excludedItems.includes(entry.name) && !EXCLUDED_MOUNT_ITEMS.includes(entry.name)) {
350
- results.push(...findExcludedPaths(fullPath, excludedItems));
351
- }
352
336
  }
353
337
  return results;
354
338
  }
@@ -672,6 +656,7 @@ export class TestExecutionService {
672
656
  // Start periodic progress reporter during execution
673
657
  // Runs in parallel with docker.run() to keep the AI agent informed
674
658
  let progressIntervalHandle;
659
+ let lastProgressPromise = Promise.resolve();
675
660
  if (onProgress) {
676
661
  let progressTick = 0;
677
662
  progressIntervalHandle = setInterval(() => {
@@ -680,7 +665,7 @@ export class TestExecutionService {
680
665
  // Progress moves from 40% to 79% during execution phase
681
666
  // Slowly increment to show activity without reaching 80% (reserved for processing)
682
667
  const percent = Math.min(40 + progressTick * 2, 79);
683
- onProgress({
668
+ lastProgressPromise = onProgress({
684
669
  phase: "executing",
685
670
  message: `Test running... (${elapsed}s elapsed)`,
686
671
  percent,
@@ -753,12 +738,12 @@ export class TestExecutionService {
753
738
  }
754
739
  throw error; // Re-throw the original error
755
740
  }
756
- // Report processing phase
757
- await onProgress?.({
758
- phase: "processing",
759
- message: "Test execution completed, processing results...",
760
- percent: 80,
761
- });
741
+ // Drain any in-flight progress notification so it flushes to the
742
+ // transport before the tool result is written. A late notification
743
+ // races with the result on MCP stdio — if the result arrives first,
744
+ // Claude Code discards the progress token and treats the stale
745
+ // notification as a fatal transport error, killing the connection.
746
+ await lastProgressPromise;
762
747
  const duration = Date.now() - startTime;
763
748
  const cleanOutput = stripVTControlCharacters(output);
764
749
  logger.info("Test execution completed", {
@@ -784,12 +769,6 @@ export class TestExecutionService {
784
769
  exitCode: statusCode,
785
770
  videoPath,
786
771
  };
787
- // Report completion
788
- await onProgress?.({
789
- phase: "processing",
790
- message: `Test execution completed: ${result.status}`,
791
- percent: 100,
792
- });
793
772
  logger.debug(`Test ${result.status}: ${options.testFile}`);
794
773
  return result;
795
774
  }
@@ -36,6 +36,14 @@ export interface BaseTestParams {
36
36
  mockPort?: number;
37
37
  optionalFields?: boolean;
38
38
  }
39
+ /**
40
+ * Derive the framework actually used for generation from the (normalized)
41
+ * language + framework params. A blank/whitespace-only framework is treated as
42
+ * unset — ts/js then default to Playwright, everything else to "". Kept in ONE
43
+ * place so validateInputs and buildBaseGenerationOptions cannot drift on the
44
+ * empty-string edge (SKYR-3899).
45
+ */
46
+ export declare function deriveEffectiveFramework(language: string | undefined, framework: string | undefined): string;
39
47
  export declare abstract class TestGenerationService {
40
48
  protected client: SkyrampClient;
41
49
  constructor();
@@ -10,6 +10,20 @@ import { logger } from "../utils/logger.js";
10
10
  import { normalizeLanguageParams } from "../utils/normalizeParams.js";
11
11
  import { stageGeneratedPaths, resolveOutputDir } from "../utils/gitStaging.js";
12
12
  import { getTestsRepoDir } from "../utils/AnalysisStateManager.js";
13
+ /**
14
+ * Derive the framework actually used for generation from the (normalized)
15
+ * language + framework params. A blank/whitespace-only framework is treated as
16
+ * unset — ts/js then default to Playwright, everything else to "". Kept in ONE
17
+ * place so validateInputs and buildBaseGenerationOptions cannot drift on the
18
+ * empty-string edge (SKYR-3899).
19
+ */
20
+ export function deriveEffectiveFramework(language, framework) {
21
+ const fw = (framework ?? "").trim().toLowerCase();
22
+ if (fw !== "")
23
+ return fw;
24
+ const lang = (language ?? "").trim().toLowerCase();
25
+ return lang === "typescript" || lang === "javascript" ? "playwright" : "";
26
+ }
13
27
  export class TestGenerationService {
14
28
  client;
15
29
  constructor() {
@@ -128,19 +142,53 @@ The generated test file remains unchanged and ready to use as-is.
128
142
  text: "Error: requestData must be either a valid JSON string or an absolute path to a file.",
129
143
  });
130
144
  }
131
- const fw = (params.framework ?? "").toLowerCase();
132
- if (fw === "playwright" && params.output && params.output !== "") {
133
- const specPattern = /\.(spec|test)\.[tj]s$/;
134
- if (!specPattern.test(params.output)) {
135
- const parsed = path.parse(params.output);
136
- const suggested = /\.[tj]s$/.test(parsed.ext)
137
- ? params.output.replace(/\.[tj]s$/, ".spec.ts")
138
- : params.output + ".spec.ts";
139
- errList.content.push({
140
- type: "text",
141
- text: `Error: Playwright requires test files to match *.{spec}.{ts,js} (got "${params.output}"). ` +
142
- `Rename to e.g. ${suggested} so Playwright can discover it.`,
143
- });
145
+ // Enforce output/language consistency using the EFFECTIVE framework the same
146
+ // derivation buildBaseGenerationOptions applies (ts/js with no explicit framework
147
+ // defaults to playwright). Reading the raw params.framework here would miss the UI
148
+ // happy path, where a test is passed as language: "typescript" WITHOUT a framework
149
+ // and the TypeScript generator's output would then land in a mismatched output file
150
+ // (e.g. TypeScript written to ui_test.py — SKYR-3899).
151
+ if (params.output && params.output !== "") {
152
+ const lang = (params.language ?? "").trim().toLowerCase();
153
+ const isPlaywright = deriveEffectiveFramework(params.language, params.framework) ===
154
+ "playwright";
155
+ // Playwright JS/TS tests must use the *.spec.{ts,js} naming Playwright
156
+ // discovers. Python Playwright (pytest-playwright) and all non-playwright
157
+ // targets validate by their language's file extension instead — so an
158
+ // unset language under Playwright follows the js/ts spec rule, while an
159
+ // explicit python/java Playwright test is checked against .py/.java
160
+ // (SKYR-3899; Playwright supports Python via pytest-playwright).
161
+ if (isPlaywright && lang !== "python" && lang !== "java") {
162
+ const specPattern = /\.(spec|test)\.[tj]s$/;
163
+ if (!specPattern.test(params.output)) {
164
+ const parsed = path.parse(params.output);
165
+ const suggested = /\.[tj]s$/.test(parsed.ext)
166
+ ? params.output.replace(/\.[tj]s$/, ".spec.ts")
167
+ : params.output + ".spec.ts";
168
+ errList.content.push({
169
+ type: "text",
170
+ text: `Error: Playwright requires test files to match *.{spec}.{ts,js} (got "${params.output}"). ` +
171
+ `Rename to e.g. ${suggested} so Playwright can discover it.`,
172
+ });
173
+ }
174
+ }
175
+ else {
176
+ const expectedExtByLanguage = {
177
+ python: ".py",
178
+ typescript: ".ts",
179
+ javascript: ".js",
180
+ java: ".java",
181
+ };
182
+ const expected = expectedExtByLanguage[lang || "python"];
183
+ if (expected && !params.output.toLowerCase().endsWith(expected)) {
184
+ const parsed = path.parse(params.output);
185
+ const suggested = path.join(parsed.dir, `${parsed.name}${expected}`);
186
+ errList.content.push({
187
+ type: "text",
188
+ text: `Error: language "${lang || "python"}" requires a ${expected} output file (got "${params.output}"). ` +
189
+ `Rename to e.g. ${suggested}.`,
190
+ });
191
+ }
144
192
  }
145
193
  }
146
194
  return errList.content.length === 0
@@ -358,10 +406,7 @@ ${result}`;
358
406
  method: params.method,
359
407
  apiSchema: params.apiSchema ? [params.apiSchema] : [],
360
408
  language: params.language ?? "python",
361
- framework: params.framework ??
362
- (params.language === "typescript" || params.language === "javascript"
363
- ? "playwright"
364
- : ""),
409
+ framework: deriveEffectiveFramework(params.language, params.framework),
365
410
  output: params.output,
366
411
  outputDir: params.outputDir,
367
412
  force: params.force ?? true,
@@ -11,7 +11,7 @@ import path from "path";
11
11
  jest.unstable_mockModule("@skyramp/skyramp", () => ({
12
12
  SkyrampClient: jest.fn().mockImplementation(() => ({})),
13
13
  }));
14
- const { TestGenerationService, BaseTestParams } = await import("./TestGenerationService.js");
14
+ const { TestGenerationService, deriveEffectiveFramework } = await import("./TestGenerationService.js");
15
15
  const { SkyrampClient } = await import("@skyramp/skyramp");
16
16
  class StubService extends TestGenerationService {
17
17
  buildGenerationOptions() {
@@ -87,6 +87,72 @@ describe("TestGenerationService — Playwright filename validation", () => {
87
87
  expect(playwrightError(validateOutput("Playwright", "bad.ts"))).toBeDefined();
88
88
  });
89
89
  });
90
+ describe("TestGenerationService — language/output extension consistency (SKYR-3899)", () => {
91
+ function validateLangOutput(language, framework, output) {
92
+ const svc = new StubService();
93
+ return svc.validate({ ...BASE, language, framework, output });
94
+ }
95
+ function errorText(result) {
96
+ for (const c of result.content) {
97
+ if (c.type === "text")
98
+ return c.text;
99
+ }
100
+ return undefined;
101
+ }
102
+ it("rejects TypeScript output written to a .py file with no explicit framework (the reported bug)", () => {
103
+ // language typescript + no framework derives to playwright, so ui_test.py is
104
+ // rejected instead of emitting TypeScript into a Python file.
105
+ const result = validateLangOutput("typescript", "", "ui_test.py");
106
+ expect(result.isError).toBe(true);
107
+ expect(errorText(result)).toContain("Playwright requires");
108
+ });
109
+ it("accepts a TypeScript UI test named *.spec.ts with no explicit framework", () => {
110
+ const result = validateLangOutput("typescript", "", "ui_test.spec.ts");
111
+ expect(result.isError).toBe(false);
112
+ });
113
+ it("rejects a Python-language test written to a .ts file", () => {
114
+ const result = validateLangOutput("python", "", "my_test.ts");
115
+ expect(result.isError).toBe(true);
116
+ expect(errorText(result)).toContain('language "python" requires a .py');
117
+ expect(errorText(result)).toContain("my_test.py");
118
+ });
119
+ it("accepts a Python-language test written to a .py file", () => {
120
+ const result = validateLangOutput("python", "", "my_test.py");
121
+ expect(result.isError).toBe(false);
122
+ });
123
+ it("rejects a mismatched extension even when a non-playwright framework is explicit", () => {
124
+ const result = validateLangOutput("typescript", "pytest", "foo.py");
125
+ expect(result.isError).toBe(true);
126
+ expect(errorText(result)).toContain('language "typescript" requires a .ts');
127
+ });
128
+ it("accepts a Python Playwright test (pytest-playwright) written to a .py file", () => {
129
+ // Playwright supports Python via pytest-playwright — language: "python" +
130
+ // framework: "playwright" must validate against .py, not the js/ts spec rule.
131
+ const result = validateLangOutput("python", "playwright", "test_login.py");
132
+ expect(result.isError).toBe(false);
133
+ });
134
+ it("rejects a Python Playwright test written to a .spec.ts file", () => {
135
+ const result = validateLangOutput("python", "playwright", "foo.spec.ts");
136
+ expect(result.isError).toBe(true);
137
+ expect(errorText(result)).toContain('language "python" requires a .py');
138
+ });
139
+ });
140
+ describe("deriveEffectiveFramework — validation/generation share one derivation (SKYR-3899)", () => {
141
+ it("derives playwright for ts/js when framework is undefined", () => {
142
+ expect(deriveEffectiveFramework("typescript", undefined)).toBe("playwright");
143
+ expect(deriveEffectiveFramework("javascript", undefined)).toBe("playwright");
144
+ });
145
+ it("treats a blank/whitespace framework as unset (no drift vs undefined)", () => {
146
+ // The empty-string edge the two call sites used to disagree on: validation
147
+ // derived playwright while generation kept "". Both now agree.
148
+ expect(deriveEffectiveFramework("typescript", "")).toBe("playwright");
149
+ expect(deriveEffectiveFramework("typescript", " ")).toBe("playwright");
150
+ });
151
+ it("keeps an explicit framework and defaults non-js/ts to empty", () => {
152
+ expect(deriveEffectiveFramework("typescript", "pytest")).toBe("pytest");
153
+ expect(deriveEffectiveFramework("python", undefined)).toBe("");
154
+ });
155
+ });
90
156
  describe("TestGenerationService — authType/authScheme not passed to library", () => {
91
157
  let mockGenerateRestTest;
92
158
  beforeEach(() => {
@@ -52,7 +52,7 @@ Logging into Skyramp provides access to additional platform features and service
52
52
  const recordParams = {
53
53
  prompt: params.prompt,
54
54
  };
55
- AnalyticsService.pushMCPToolEvent(TOOL_NAME, errorResult, recordParams);
55
+ AnalyticsService.pushMCPToolEvent(TOOL_NAME, errorResult, recordParams).catch((err) => { logger.warning("Analytics event failed", { error: String(err) }); });
56
56
  }
57
57
  });
58
58
  }