@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.
- package/build/prompts/test-maintenance/actionsInstructions.js +4 -2
- package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +9 -0
- package/build/prompts/test-maintenance/drift-analysis-prompt.js +8 -7
- package/build/prompts/test-maintenance/driftAnalysisSections.js +4 -5
- package/build/prompts/test-maintenance/driftAnalysisShared.d.ts +11 -0
- package/build/prompts/test-maintenance/driftAnalysisShared.js +22 -6
- package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +23 -31
- package/build/prompts/test-recommendation/diffExecutionPlan.d.ts +7 -1
- package/build/prompts/test-recommendation/diffExecutionPlan.js +20 -6
- package/build/prompts/test-recommendation/scopeAssessment.d.ts +10 -1
- package/build/prompts/test-recommendation/scopeAssessment.js +27 -2
- package/build/prompts/test-recommendation/scopeAssessment.test.js +35 -0
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +5 -1
- package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +61 -0
- package/build/prompts/testbot/testbot-prompts.js +7 -5
- package/build/prompts/testbot/testbot-prompts.test.js +1 -1
- package/build/services/AnalyticsService.js +37 -2
- package/build/services/TestDiscoveryService.d.ts +38 -4
- package/build/services/TestDiscoveryService.js +187 -16
- package/build/services/TestDiscoveryService.test.js +232 -10
- package/build/services/TestExecutionService.d.ts +12 -1
- package/build/services/TestExecutionService.js +20 -41
- package/build/services/TestGenerationService.d.ts +8 -0
- package/build/services/TestGenerationService.js +62 -17
- package/build/services/TestGenerationService.test.js +67 -1
- package/build/tools/auth/loginTool.js +1 -1
- package/build/tools/auth/logoutTool.js +1 -1
- package/build/tools/code-refactor/codeReuseTool.js +1 -1
- package/build/tools/code-refactor/modularizationTool.js +1 -1
- package/build/tools/executeSkyrampTestTool.js +30 -1
- package/build/tools/fixErrorTool.js +1 -1
- package/build/tools/one-click/oneClickTool.js +1 -1
- package/build/tools/test-management/actionsTool.js +76 -41
- package/build/tools/test-management/actionsTool.test.js +104 -38
- package/build/tools/test-management/analyzeChangesTool.js +102 -4
- package/build/tools/test-management/analyzeChangesTool.test.js +3 -1
- package/build/tools/test-management/analyzeTestHealthTool.js +9 -1
- package/build/tools/test-management/analyzeTestHealthTool.test.js +1 -1
- package/build/tools/trace/startTraceCollectionTool.js +1 -1
- package/build/tools/trace/stopTraceCollectionTool.js +1 -1
- package/build/tools/workspace/initScanWorkspaceTool.js +1 -1
- package/build/tools/workspace/initializeWorkspaceTool.js +1 -1
- package/build/tools/workspace/initializeWorkspaceTool.test.js +1 -1
- package/build/types/RepositoryAnalysis.d.ts +6 -6
- package/build/types/TestAnalysis.d.ts +13 -0
- package/build/utils/dartRouteExtractor.js +8 -30
- package/build/utils/docker.test.js +1 -1
- package/build/utils/fileWalk.d.ts +71 -0
- package/build/utils/fileWalk.js +79 -0
- package/build/utils/fileWalk.test.d.ts +1 -0
- package/build/utils/fileWalk.test.js +252 -0
- package/build/utils/frontendIntegration.js +20 -36
- package/build/utils/frontendSelectors.d.ts +34 -0
- package/build/utils/frontendSelectors.js +124 -0
- package/build/utils/frontendSelectors.test.d.ts +1 -0
- package/build/utils/frontendSelectors.test.js +118 -0
- package/build/utils/importerHop.d.ts +18 -0
- package/build/utils/importerHop.js +48 -3
- package/build/utils/importerHop.test.js +71 -1
- package/build/utils/pythonMountPrefixes.d.ts +25 -0
- package/build/utils/pythonMountPrefixes.js +347 -0
- package/build/utils/pythonMountPrefixes.test.d.ts +1 -0
- package/build/utils/pythonMountPrefixes.test.js +113 -0
- package/build/utils/repoScanner.js +30 -23
- package/build/utils/repoScanner.test.js +98 -0
- package/build/utils/routeParsers.d.ts +7 -1
- package/build/utils/routeParsers.js +32 -18
- package/build/utils/sourceRouteExtractor.js +17 -40
- package/build/utils/trace-parser.js +7 -19
- package/build/utils/versions.d.ts +3 -3
- package/build/utils/versions.js +1 -1
- package/build/utils/walkerCharacterization.test.d.ts +1 -0
- package/build/utils/walkerCharacterization.test.js +233 -0
- package/node_modules/playwright/lib/common/config.js +1 -1
- package/node_modules/playwright/lib/common/configLoader.js +1 -1
- package/node_modules/playwright/lib/common/fixtures.js +1 -1
- package/node_modules/playwright/lib/common/testType.js +1 -1
- package/node_modules/playwright/lib/index.js +2 -2
- package/node_modules/playwright/lib/isomorphic/testTree.js +1 -1
- package/node_modules/playwright/lib/matchers/expect.js +1 -1
- package/node_modules/playwright/lib/mcp/browser/tab.js +1 -1
- package/node_modules/playwright/lib/mcp/browser/tools/navigate.js +1 -1
- package/node_modules/playwright/lib/mcp/browser/tools/tracing.js +1 -1
- package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +7 -0
- package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +2 -1
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +3 -1
- package/node_modules/playwright/lib/reporters/base.js +2 -2
- package/node_modules/playwright/lib/reporters/list.js +1 -1
- package/node_modules/playwright/lib/reporters/teleEmitter.js +1 -1
- package/node_modules/playwright/lib/worker/testInfo.js +2 -2
- package/node_modules/playwright/node_modules/playwright-core/ThirdPartyNotices.txt +23 -126
- package/node_modules/playwright/node_modules/playwright-core/lib/generated/injectedScriptSource.js +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/generated/pollingRecorderSource.js +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +2 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-Cqp3cwEJ.js → codeMirrorModule-aszq5EdG.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{defaultSettingsView-CYf9adZh.js → defaultSettingsView-BxS7Jm4s.js} +94 -94
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.d4gkVSou.js → index.D4JTTy4R.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{uiMode.u4_8VnCV.js → uiMode.DaRMQKOI.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
- package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
- package/node_modules/playwright/node_modules/playwright-core/src/generated/injectedScriptSource.ts +1 -1
- package/node_modules/playwright/node_modules/playwright-core/src/generated/pollingRecorderSource.ts +1 -1
- package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
- package/node_modules/playwright/package.json +1 -1
- package/package.json +3 -3
- package/node_modules/playwright/._ThirdPartyNotices.txt +0 -0
- package/node_modules/playwright/._cli.js +0 -0
- package/node_modules/playwright/._index.d.ts +0 -0
- package/node_modules/playwright/._index.js +0 -0
- package/node_modules/playwright/._index.mjs +0 -0
- package/node_modules/playwright/._jsx-runtime.js +0 -0
- package/node_modules/playwright/._jsx-runtime.mjs +0 -0
- package/node_modules/playwright/._lib +0 -0
- package/node_modules/playwright/._node_modules +0 -0
- package/node_modules/playwright/._package.json +0 -0
- package/node_modules/playwright/._test.d.ts +0 -0
- package/node_modules/playwright/._test.js +0 -0
- package/node_modules/playwright/._test.mjs +0 -0
- package/node_modules/playwright/._types +0 -0
- package/node_modules/playwright/._vitest.config.ts +0 -0
- package/node_modules/playwright/node_modules/playwright-core/._.DS_Store +0 -0
- package/node_modules/playwright/node_modules/playwright-core/._.npmignore +0 -0
- package/node_modules/playwright/node_modules/playwright-core/._README.md +0 -0
- package/node_modules/playwright/node_modules/playwright-core/._ThirdPartyNotices.txt +0 -0
- package/node_modules/playwright/node_modules/playwright-core/._bin +0 -0
- package/node_modules/playwright/node_modules/playwright-core/._browsers.json +0 -0
- package/node_modules/playwright/node_modules/playwright-core/._bundles +0 -0
- package/node_modules/playwright/node_modules/playwright-core/._cli.js +0 -0
- package/node_modules/playwright/node_modules/playwright-core/._index.d.ts +0 -0
- package/node_modules/playwright/node_modules/playwright-core/._index.js +0 -0
- package/node_modules/playwright/node_modules/playwright-core/._index.mjs +0 -0
- package/node_modules/playwright/node_modules/playwright-core/._lib +0 -0
- package/node_modules/playwright/node_modules/playwright-core/._package.json +0 -0
- package/node_modules/playwright/node_modules/playwright-core/._src +0 -0
- 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"],
|
|
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"],
|
|
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
|
-
//
|
|
624
|
+
// UI promotion gating: no changed frontend files ⇒ no .e2e.ts promotion
|
|
597
625
|
// ---------------------------------------------------------------------------
|
|
598
|
-
describe("TestDiscoveryService —
|
|
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
|
|
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) +
|
|
615
|
-
const result = await service.discoverTests(tmpDir, { changedResources: []
|
|
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
|
|
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: [],
|
|
622
|
-
// .e2e.ts should
|
|
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.
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if (entry.
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
337
|
-
|
|
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 (
|
|
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
|
-
//
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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,
|
|
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
|
}
|