@skyramp/mcp 0.2.8 → 0.2.10-rc.1

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 (88) hide show
  1. package/build/index.js +1 -8
  2. package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +28 -1
  3. package/build/prompts/initialize-workspace/initializeWorkspacePrompt.js +1 -0
  4. package/build/prompts/personas.d.ts +2 -2
  5. package/build/prompts/personas.js +2 -2
  6. package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.js +3 -1
  7. package/build/prompts/sut-setup/modes/dockerComposePrompt.js +6 -1
  8. package/build/prompts/sut-setup/shared.d.ts +20 -0
  9. package/build/prompts/sut-setup/shared.js +67 -7
  10. package/build/prompts/test-maintenance/actionsInstructions.js +5 -1
  11. package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +15 -2
  12. package/build/prompts/test-maintenance/drift-analysis-prompt.js +63 -3
  13. package/build/prompts/test-maintenance/driftAnalysisSections.js +7 -25
  14. package/build/prompts/test-maintenance/driftAnalysisShared.d.ts +20 -0
  15. package/build/prompts/test-maintenance/driftAnalysisShared.js +80 -0
  16. package/build/prompts/test-maintenance/uiDriftAnalysisSections.d.ts +32 -0
  17. package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +212 -0
  18. package/build/prompts/test-recommendation/recommendationShared.js +1 -1
  19. package/build/prompts/test-recommendation/scopeAssessment.js +12 -2
  20. package/build/prompts/testbot/testbot-prompts.js +10 -9
  21. package/build/resources/testbotResource.js +1 -1
  22. package/build/services/TestDiscoveryService.d.ts +12 -10
  23. package/build/services/TestDiscoveryService.js +137 -83
  24. package/build/services/TestDiscoveryService.test.js +370 -15
  25. package/build/services/TestExecutionService.d.ts +1 -1
  26. package/build/services/TestExecutionService.js +7 -7
  27. package/build/services/TestExecutionService.test.js +4 -1
  28. package/build/tools/executeSkyrampTestTool.js +40 -71
  29. package/build/tools/submitReportTool.d.ts +25 -25
  30. package/build/tools/submitReportTool.js +123 -34
  31. package/build/tools/submitReportTool.test.js +413 -94
  32. package/build/tools/test-management/actionsTool.js +177 -77
  33. package/build/tools/test-management/actionsTool.test.d.ts +1 -0
  34. package/build/tools/test-management/actionsTool.test.js +297 -0
  35. package/build/tools/test-management/analyzeChangesTool.d.ts +9 -0
  36. package/build/tools/test-management/analyzeChangesTool.js +74 -169
  37. package/build/tools/test-management/analyzeChangesTool.test.js +34 -3
  38. package/build/tools/test-management/analyzeTestHealthTool.js +63 -22
  39. package/build/tools/test-management/analyzeTestHealthTool.test.d.ts +1 -0
  40. package/build/tools/test-management/analyzeTestHealthTool.test.js +268 -0
  41. package/build/tools/workspace/initializeWorkspaceTool.js +37 -24
  42. package/build/tools/workspace/initializeWorkspaceTool.test.js +9 -4
  43. package/build/types/FrontendIntegration.d.ts +25 -0
  44. package/build/types/FrontendIntegration.js +19 -0
  45. package/build/types/RepositoryAnalysis.d.ts +8 -8
  46. package/build/types/TestAnalysis.d.ts +18 -49
  47. package/build/types/TestAnalysis.js +0 -28
  48. package/build/types/TestTypes.d.ts +3 -1
  49. package/build/types/TestTypes.js +3 -1
  50. package/build/types/TestbotReport.d.ts +1 -1
  51. package/build/utils/AnalysisStateManager.d.ts +16 -22
  52. package/build/utils/docker.test.js +1 -1
  53. package/build/utils/featureFlags.d.ts +1 -1
  54. package/build/utils/featureFlags.js +1 -1
  55. package/build/utils/frontendIntegration.d.ts +9 -0
  56. package/build/utils/frontendIntegration.js +237 -0
  57. package/build/utils/frontendIntegration.test.d.ts +1 -0
  58. package/build/utils/frontendIntegration.test.js +229 -0
  59. package/build/utils/pr-comment-parser.d.ts +3 -3
  60. package/build/utils/pr-comment-parser.js +6 -6
  61. package/build/utils/pr-comment-parser.test.js +3 -3
  62. package/build/utils/repoScanner.d.ts +7 -0
  63. package/build/utils/repoScanner.js +14 -6
  64. package/build/utils/repoScanner.test.js +9 -9
  65. package/build/utils/routeParsers.d.ts +32 -0
  66. package/build/utils/routeParsers.js +205 -2
  67. package/build/utils/routeParsers.test.js +279 -29
  68. package/build/utils/sourceRouteExtractor.js +174 -5
  69. package/build/utils/sourceRouteExtractor.test.js +173 -0
  70. package/build/utils/utils.d.ts +11 -0
  71. package/build/utils/utils.js +19 -0
  72. package/build/utils/utils.test.js +23 -1
  73. package/build/utils/versions.d.ts +3 -3
  74. package/build/utils/versions.js +1 -1
  75. package/build/workspace/workspace.d.ts +21 -21
  76. package/build/workspace/workspace.js +7 -4
  77. package/build/workspace/workspace.test.js +65 -6
  78. package/node_modules/playwright/lib/mcp/browser/tools/files.js +8 -3
  79. package/node_modules/playwright/lib/mcp/browser/tools/wait.js +1 -1
  80. package/node_modules/playwright/lib/mcp/skyramp/exportTool.js +10 -1
  81. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +33 -9
  82. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +7 -1
  83. package/node_modules/playwright/lib/mcp/test/skyRampExport.test.js +44 -0
  84. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.B_7ywgmr.js → index.d4gkVSou.js} +1 -1
  85. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
  86. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  87. package/node_modules/playwright/package.json +1 -1
  88. package/package.json +3 -3
@@ -308,13 +308,12 @@ describe("TestDiscoveryService", () => {
308
308
  writeFile("test_products_api.py", 'import requests\nrequests.get("/api/products")');
309
309
  const result = await service.discoverTests(tmpDir, { changedResources: ["orders"] });
310
310
  const externalTests = result.tests.filter((t) => t.source === TestSource.External);
311
- expect(externalTests.length).toBe(2);
311
+ // Only the relevant file (orders) is included — low-relevance files are excluded entirely.
312
+ expect(externalTests.length).toBe(1);
312
313
  const ordersTest = externalTests.find(t => t.testFile.includes("orders"));
313
- const productsTest = externalTests.find(t => t.testFile.includes("products"));
314
- // Relevant file gets endpoint extraction
315
314
  expect(ordersTest?.apiEndpoint).toContain("GET /api/orders");
316
- // Low-relevance file gets name-only entry (no endpoint extraction)
317
- expect(productsTest?.apiEndpoint).toBe("");
315
+ // Low-relevance products test is not in result.tests at all.
316
+ expect(externalTests.find(t => t.testFile.includes("products"))).toBeUndefined();
318
317
  });
319
318
  it("returns relevantExternalTestPaths including files where extraction may have failed", async () => {
320
319
  writeFile("test_orders_api.py", 'import requests\nrequests.get("/api/orders")');
@@ -380,27 +379,383 @@ describe("TestDiscoveryService", () => {
380
379
  // External tests suppressed in PR-mode-no-endpoints
381
380
  expect(externalTests.length).toBe(0);
382
381
  });
383
- it("returns external tests as name-only with ['unknown'] sentinel (unresolvable resources)", async () => {
382
+ it("returns no external tests with ['unknown'] sentinel (unresolvable resources)", async () => {
384
383
  // When diff endpoints exist but all paths resolve to "unknown" (e.g. decorator-relative
385
- // paths like "/{order_id}"), changedResources = ["unknown"]. External tests should be
386
- // discovered (not skipped) but scored as low-relevance since "unknown" won't match filenames.
384
+ // paths like "/{order_id}"), changedResources = ["unknown"]. No external tests score > 0
385
+ // since "unknown" doesn't match any filename tokens — low-relevance files are excluded.
387
386
  writeFile("test_orders_api.py", 'import requests\nrequests.get("/api/orders")');
388
387
  writeFile("test_products_api.py", 'import requests\nrequests.get("/api/products")');
389
388
  const result = await service.discoverTests(tmpDir, { changedResources: ["unknown"] });
390
389
  const externalTests = result.tests.filter(t => t.source === TestSource.External);
391
- // External tests discovered (not skipped like empty array)
392
- expect(externalTests.length).toBe(2);
393
- // But all are low-relevance (name-only) since "unknown" doesn't match any filename tokens
390
+ expect(externalTests.length).toBe(0);
394
391
  expect(result.relevantExternalTestPaths.length).toBe(0);
395
392
  });
396
- it("low-relevance files have empty apiEndpoint and empty framework in PR mode", async () => {
393
+ it("low-relevance files are excluded from result.tests in PR mode", async () => {
397
394
  writeFile("test_orders_api.py", 'import requests\nrequests.get("/api/orders")');
398
395
  writeFile("test_products_api.py", 'import requests\nrequests.get("/api/products")');
399
396
  const result = await service.discoverTests(tmpDir, { changedResources: ["orders"] });
397
+ // products scored 0 — excluded entirely, not even as name-only entry
400
398
  const productsTest = result.tests.find(t => t.testFile.includes("test_products_api"));
401
- expect(productsTest?.source).toBe(TestSource.External);
402
- expect(productsTest?.apiEndpoint).toBe("");
403
- expect(productsTest?.framework).toBe("");
399
+ expect(productsTest).toBeUndefined();
400
+ });
401
+ });
402
+ });
403
+ // ---------------------------------------------------------------------------
404
+ // Framework detection ordering — private method behaviour tested via discoverTests
405
+ // ---------------------------------------------------------------------------
406
+ describe("TestDiscoveryService — detectExternalFramework ordering", () => {
407
+ let service;
408
+ let tmpDir;
409
+ beforeEach(() => {
410
+ service = new TestDiscoveryService();
411
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tds-fw-"));
412
+ });
413
+ afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); });
414
+ function writeFile(rel, content) {
415
+ const fp = path.join(tmpDir, rel);
416
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
417
+ fs.writeFileSync(fp, content, "utf-8");
418
+ return fp;
419
+ }
420
+ it("pytest-playwright file → playwright-ui (not pytest)", async () => {
421
+ writeFile("tests/test_login.py", "import pytest\nfrom playwright.sync_api import Page\ndef test_login(page: Page):\n page.goto('http://localhost')");
422
+ const result = await service.discoverTests(tmpDir, { changedResources: ["login"] });
423
+ const t = result.tests.find(f => f.testFile.includes("test_login.py"));
424
+ expect(t?.framework).toBe("playwright-ui");
425
+ });
426
+ it("Playwright API-only test (request, no page) → playwright (not playwright-ui)", async () => {
427
+ writeFile("tests/test_api.py", "from playwright.sync_api import APIRequestContext\ndef test_orders(request: APIRequestContext):\n request.get('/api/orders')");
428
+ const result = await service.discoverTests(tmpDir, { changedResources: ["orders"] });
429
+ const t = result.tests.find(f => f.testFile.includes("test_api.py"));
430
+ expect(t?.framework).toBe("playwright");
431
+ });
432
+ it("pure pytest file → pytest", async () => {
433
+ writeFile("tests/test_calc.py", "import pytest\ndef test_add():\n assert 1 + 1 == 2");
434
+ const result = await service.discoverTests(tmpDir, { changedResources: ["calc"] });
435
+ const t = result.tests.find(f => f.testFile.includes("test_calc.py"));
436
+ expect(t?.framework).toBe("pytest");
437
+ });
438
+ it("RTL file with it()/expect() → rtl (not jest)", async () => {
439
+ 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 });
441
+ const t = result.tests.find(f => f.testFile.includes("Cart.test.tsx"));
442
+ expect(t?.framework).toBe("rtl");
443
+ });
444
+ it("vue-test-utils file → vue-test-utils (not jest)", async () => {
445
+ 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 });
447
+ const t = result.tests.find(f => f.testFile.includes("Counter.test.ts"));
448
+ expect(t?.framework).toBe("vue-test-utils");
449
+ });
450
+ it("JS Playwright file using page.goto() → playwright-ui", async () => {
451
+ writeFile("e2e/login.spec.ts", "import { test } from '@playwright/test';\ntest('login', async ({ page }) => { await page.goto('/'); });");
452
+ const result = await service.discoverTests(tmpDir, { changedResources: ["login"] });
453
+ const t = result.tests.find(f => f.testFile.includes("login.spec.ts"));
454
+ expect(t?.framework).toBe("playwright-ui");
455
+ });
456
+ it("JS Playwright using only request fixture → playwright", async () => {
457
+ writeFile("e2e/api.spec.ts", "import { test } from '@playwright/test';\ntest('api', async ({ request }) => { await request.get('/api'); });");
458
+ const result = await service.discoverTests(tmpDir, { changedResources: ["api"] });
459
+ const t = result.tests.find(f => f.testFile.includes("api.spec.ts"));
460
+ expect(t?.framework).toBe("playwright");
461
+ });
462
+ });
463
+ // ---------------------------------------------------------------------------
464
+ // detectExternalTestType path rules
465
+ // ---------------------------------------------------------------------------
466
+ describe("TestDiscoveryService — detectExternalTestType path rules", () => {
467
+ let service;
468
+ let tmpDir;
469
+ beforeEach(() => {
470
+ service = new TestDiscoveryService();
471
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tds-type-"));
472
+ });
473
+ afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); });
474
+ function writeFile(rel, content) {
475
+ const fp = path.join(tmpDir, rel);
476
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
477
+ fs.writeFileSync(fp, content, "utf-8");
478
+ return fp;
479
+ }
480
+ it("file in playwright/ dir → testType=e2e", async () => {
481
+ writeFile("apps/web/playwright/login.spec.ts", "import { test } from '@playwright/test';\ntest('login', async ({ page }) => { await page.goto('/'); });");
482
+ const result = await service.discoverTests(tmpDir, { changedResources: ["login"] });
483
+ const t = result.tests.find(f => f.testFile.includes("login.spec.ts"));
484
+ expect(t?.testType).toBe("e2e");
485
+ });
486
+ it("file with e2e in filename inside e2e/ dir → testType=e2e", async () => {
487
+ // .e2e.ts in an e2e/ dir is recognized both by dir pattern and filename
488
+ writeFile("apps/web/e2e/auth.e2e.ts", "import { test } from '@playwright/test';\ntest('auth', async ({ page }) => { await page.goto('/'); });");
489
+ const result = await service.discoverTests(tmpDir, { changedResources: ["auth"] });
490
+ const t = result.tests.find(f => f.testFile.includes("auth.e2e.ts"));
491
+ expect(t?.testType).toBe("e2e");
492
+ });
493
+ it("file in api dir (no playwright in path) → NOT e2e", async () => {
494
+ writeFile("apps/api/v2/tests/login.spec.ts", "import { test } from '@jest/globals';\ntest('login', () => { expect(true).toBe(true); });");
495
+ const result = await service.discoverTests(tmpDir, { changedResources: ["login"] });
496
+ const t = result.tests.find(f => f.testFile.includes("login.spec.ts"));
497
+ expect(t?.testType).not.toBe("e2e");
498
+ });
499
+ it("generic spec file (no e2e/playwright signal) → NOT e2e", async () => {
500
+ writeFile("modules/bookings/login.spec.ts", "import { test } from '@jest/globals';\ntest('login', () => {});");
501
+ const result = await service.discoverTests(tmpDir, { changedResources: ["login"] });
502
+ const t = result.tests.find(f => f.testFile.includes("login.spec.ts"));
503
+ expect(t?.testType).not.toBe("e2e");
504
+ });
505
+ });
506
+ // ---------------------------------------------------------------------------
507
+ // isFrontendFile edge cases
508
+ // ---------------------------------------------------------------------------
509
+ const { isFrontendFile } = await import("../prompts/test-recommendation/scopeAssessment.js");
510
+ describe("isFrontendFile edge cases", () => {
511
+ it("pages/api/orders.tsx → false (Next.js API route, /api/ blocks all)", () => {
512
+ expect(isFrontendFile("pages/api/orders.tsx")).toBe(false);
513
+ });
514
+ it("routes/product-card.tsx → true (.tsx in routes/ is page component)", () => {
515
+ expect(isFrontendFile("routes/product-card.tsx")).toBe(true);
516
+ });
517
+ it("routes/orders.ts → false (.ts in routes/ is route handler)", () => {
518
+ expect(isFrontendFile("routes/orders.ts")).toBe(false);
519
+ });
520
+ it("src/components/Cart.tsx → true", () => {
521
+ expect(isFrontendFile("src/components/Cart.tsx")).toBe(true);
522
+ });
523
+ it("src/utils/helpers.ts → false (ambiguous extension, not in frontend dir)", () => {
524
+ expect(isFrontendFile("src/utils/helpers.ts")).toBe(false);
525
+ });
526
+ it("app/modules/booking/BookingList.tsx → true", () => {
527
+ expect(isFrontendFile("app/modules/booking/BookingList.tsx")).toBe(true);
528
+ });
529
+ });
530
+ // ---------------------------------------------------------------------------
531
+ // Content-based relevance cap (MAX_CONTENT_PROMOTED = 5)
532
+ // ---------------------------------------------------------------------------
533
+ describe("TestDiscoveryService — content-based relevance cap", () => {
534
+ let service;
535
+ let tmpDir;
536
+ beforeEach(() => {
537
+ service = new TestDiscoveryService();
538
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tds-cap-"));
539
+ });
540
+ afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); });
541
+ function writeFile(rel, content) {
542
+ const fp = path.join(tmpDir, rel);
543
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
544
+ fs.writeFileSync(fp, content, "utf-8");
545
+ return fp;
546
+ }
547
+ it("promotes at most MAX_CONTENT_PROMOTED=5 files via content match", async () => {
548
+ // 10 test files that all reference /orders in their content
549
+ for (let i = 0; i < 10; i++) {
550
+ writeFile(`tests/test_feature_${i}.py`, `import requests\nrequests.get("/api/checkout_${i}")`);
551
+ // content-match: all contain "/orders"
552
+ fs.writeFileSync(path.join(tmpDir, `tests/test_feature_${i}.py`), `import requests\n# tests ordering\nrequests.get("/api/orders/item_${i}")`);
553
+ }
554
+ // changedResources=["unknown"] so all score 0 by filename; content scan fires
555
+ const result = await service.discoverTests(tmpDir, { changedResources: ["orders"] });
556
+ // At most 5 content-matched files should be in relevantExternalTestPaths
557
+ expect(result.relevantExternalTestPaths.length).toBeLessThanOrEqual(5);
558
+ });
559
+ });
560
+ // ---------------------------------------------------------------------------
561
+ // scoreRelevanceByContent regex safety (dot/plus escaped)
562
+ // ---------------------------------------------------------------------------
563
+ describe("TestDiscoveryService — scoreRelevanceByContent regex safety", () => {
564
+ let service;
565
+ let tmpDir;
566
+ beforeEach(() => {
567
+ service = new TestDiscoveryService();
568
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tds-regex-"));
569
+ });
570
+ afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); });
571
+ function writeFile(rel, content) {
572
+ const fp = path.join(tmpDir, rel);
573
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
574
+ fs.writeFileSync(fp, content, "utf-8");
575
+ return fp;
576
+ }
577
+ it("resource with dot (order.items) does not match orderXitems via unescaped dot", async () => {
578
+ writeFile("tests/test_order.py", `import requests\nrequests.get("/api/orderXitems")`);
579
+ // "order.items" in changedResources — dot must be escaped
580
+ const result = await service.discoverTests(tmpDir, { changedResources: ["order.items"] });
581
+ // should NOT match (orderXitems ≠ order.items)
582
+ expect(result.relevantExternalTestPaths.some(p => p.includes("test_order.py"))).toBe(false);
583
+ });
584
+ it("resource with dot matches correctly when content has /order.items", async () => {
585
+ writeFile("tests/test_order_items.py", `import requests\nrequests.get("/api/order.items/list")`);
586
+ const result = await service.discoverTests(tmpDir, { changedResources: ["order.items"] });
587
+ expect(result.relevantExternalTestPaths.some(p => p.includes("test_order_items.py"))).toBe(true);
588
+ });
589
+ it("resource with plus does not throw SyntaxError", async () => {
590
+ writeFile("tests/test_v2.py", `import requests\nrequests.get("/api/v2/items")`);
591
+ // Should not throw
592
+ await expect(service.discoverTests(tmpDir, { changedResources: ["v2+api"] })).resolves.toBeDefined();
593
+ });
594
+ });
595
+ // ---------------------------------------------------------------------------
596
+ // hasFrontendChanges=false: no .e2e.ts promotion
597
+ // ---------------------------------------------------------------------------
598
+ describe("TestDiscoveryService — hasFrontendChanges=false guard", () => {
599
+ let service;
600
+ let tmpDir;
601
+ beforeEach(() => {
602
+ service = new TestDiscoveryService();
603
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tds-nofe-"));
604
+ });
605
+ afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); });
606
+ function writeFile(rel, content) {
607
+ const fp = path.join(tmpDir, rel);
608
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
609
+ fs.writeFileSync(fp, content, "utf-8");
610
+ return fp;
611
+ }
612
+ it("does not promote .e2e.ts files when hasFrontendChanges=false", async () => {
613
+ 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 });
616
+ expect(result.relevantExternalTestPaths.length).toBe(0);
617
+ expect(result.tests.length).toBe(0);
618
+ });
619
+ it("promotes .e2e.ts files when hasFrontendChanges=true", async () => {
620
+ 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)
623
+ expect(result.tests.some(t => t.testFile.includes("onboarding.e2e.ts"))).toBe(true);
624
+ });
625
+ // ── Step 3: API test promotion ─────────────────────────────────────────────
626
+ // Uses a minimal mirror of the prefect-testb file structure to validate that
627
+ // the content-scored promotion finds test_deployments.py (which imports from
628
+ // the changed module) but not unrelated schedule/events test files.
629
+ describe("Step 3 API promotion (hasApiChanges)", () => {
630
+ // Realistic import lines taken from prefect-testb actual files.
631
+ // test_deployments.py imports from schemas.actions — matches the import-context
632
+ // pattern "schemas[./]actions" built when schemas/actions.py changes.
633
+ const TEST_DEPLOYMENTS_CONTENT = `\
634
+ import pytest
635
+ from httpx import AsyncClient
636
+ from prefect.server.schemas.actions import DeploymentCreate, DeploymentUpdate
637
+ from prefect.server import models, schemas
638
+
639
+ class TestCreateDeployment:
640
+ async def test_create_deployment(self, client):
641
+ response = await client.post("/api/deployments/", json={"name": "test"})
642
+ assert response.status_code == 201
643
+
644
+ class TestCreateFlowRunFromDeployment:
645
+ async def test_create_flow_run(self, client, deployment):
646
+ response = await client.post(f"/api/deployments/{deployment.id}/create_flow_run", json={})
647
+ assert response.status_code == 201
648
+ `;
649
+ // test_deployment_schedules.py imports from schedules, NOT schemas.actions
650
+ const TEST_DEPLOYMENT_SCHEDULES_CONTENT = `\
651
+ import pytest
652
+ from prefect.server import models, schemas
653
+ from prefect.server.schemas.core import DeploymentSchedule
654
+
655
+ class TestReadDeploymentSchedules:
656
+ async def test_read_schedules(self, client, deployment):
657
+ response = await client.get(f"/api/deployments/{deployment.id}/schedules")
658
+ assert response.status_code == 200
659
+ `;
660
+ // events/actions file has no deployment schema imports at all
661
+ const TEST_EVENTS_ACTIONS_CONTENT = `\
662
+ import pytest
663
+ from prefect.server.events import actions
664
+ from prefect.server.events.clients import AssertingEventsClient
665
+
666
+ class TestPausingWorkQueue:
667
+ async def test_pausing(self, session):
668
+ action = actions.PauseWorkQueue(work_queue_id="test")
669
+ await action.act(event=None)
670
+ `;
671
+ // test_schedules.py — no deployment imports
672
+ const TEST_SCHEDULES_CONTENT = `\
673
+ import pytest
674
+ from prefect.server.schemas.schedules import CronSchedule, IntervalSchedule
675
+
676
+ class TestCronSchedule:
677
+ def test_cron_parse(self):
678
+ s = CronSchedule(cron="0 * * * *")
679
+ assert s.cron == "0 * * * *"
680
+ `;
681
+ it("content-matches test_deployments.py when schemas/actions.py changed", async () => {
682
+ // Mirror the Prefect directory structure
683
+ writeFile("tests/server/orchestration/api/test_deployments.py", TEST_DEPLOYMENTS_CONTENT);
684
+ writeFile("tests/server/orchestration/api/test_deployment_schedules.py", TEST_DEPLOYMENT_SCHEDULES_CONTENT);
685
+ writeFile("tests/events/server/actions/test_pausing_resuming_work_queue.py", TEST_EVENTS_ACTIONS_CONTENT);
686
+ writeFile("tests/test_schedules.py", TEST_SCHEDULES_CONTENT);
687
+ // changedResources = ["unknown"] because "actions" is filtered by GENERIC_SCHEMA_NAMES
688
+ // in analyzeChangesTool. Step 3 is skipped for ["unknown"] sentinel.
689
+ // So we use the import-context pattern directly via hasApiChanges + a non-unknown resource.
690
+ // Simulate: schemas/actions.py changed, but it produced a non-generic parent resource.
691
+ // For this test, use changedResources derived from the parent path "schemas":
692
+ // "schemas" is in GENERIC_SCHEMA_NAMES so would be filtered → ["unknown"].
693
+ // Step 3 should be skipped → test_deployments.py not promoted via Step 3.
694
+ const resultUnknown = await service.discoverTests(tmpDir, {
695
+ changedResources: ["unknown"],
696
+ });
697
+ // Step 3 skipped for ["unknown"] — no content-matched files
698
+ const promotedUnknown = resultUnknown.relevantExternalTestPaths;
699
+ expect(promotedUnknown.some(p => p.includes("test_deployments.py"))).toBe(false);
700
+ });
701
+ it("content-matches test_deployments.py when api/deployments.py changed (TM17 scenario)", async () => {
702
+ writeFile("tests/server/orchestration/api/test_deployments.py", TEST_DEPLOYMENTS_CONTENT);
703
+ writeFile("tests/server/orchestration/api/test_deployment_schedules.py", TEST_DEPLOYMENT_SCHEDULES_CONTENT);
704
+ writeFile("tests/events/server/actions/test_pausing_resuming_work_queue.py", TEST_EVENTS_ACTIONS_CONTENT);
705
+ writeFile("tests/test_schedules.py", TEST_SCHEDULES_CONTENT);
706
+ // changedResources = ["deployments"] (from api/deployments.py route file)
707
+ // Content pattern: /import\s+[^\n]*["']\/api\/deployments/i or /deployments/ prefix
708
+ // test_deployments.py calls POST /api/deployments/ → content-matched in Step 3
709
+ const result = await service.discoverTests(tmpDir, {
710
+ changedResources: ["deployments"],
711
+ });
712
+ const promoted = result.relevantExternalTestPaths;
713
+ // test_deployments.py: calls /api/deployments/ → content-matched → promoted unconditionally
714
+ expect(promoted.some(p => p.includes("test_deployments.py"))).toBe(true);
715
+ // test_deployment_schedules.py: contains /api/deployments/ in URL calls → also content-matched
716
+ // (the word "deployments" appears in its URL strings even though it tests schedules)
717
+ // This is expected — it IS in scope for a deployment endpoint change.
718
+ // test_schedules.py: no /deployments/ URL → scores 0 → fallback only (may or may not be in promoted)
719
+ // test_pausing_resuming: no /deployments/ URL → fallback only
720
+ });
721
+ it("API external tests no longer pre-loaded (agent greps instead)", async () => {
722
+ // External API tests are discovered by the agent via the grep instruction in the
723
+ // drift analysis prompt. discoverTests in PR mode (changedResources=[]) returns
724
+ // only Skyramp tests; external tests are excluded entirely.
725
+ writeFile("tests/server/orchestration/api/test_deployments.py", [
726
+ "from prefect.server import utils",
727
+ "",
728
+ "async def test_max_active_runs(client, deployment):",
729
+ " count = utils.calculate_max_runs(deployment.id, db)",
730
+ " assert count >= 0",
731
+ ].join("\n"));
732
+ const result = await service.discoverTests(tmpDir, {
733
+ changedResources: [], // PR mode, no pre-loading of external API tests
734
+ });
735
+ // No external tests pre-loaded — agent discovers via grep
736
+ expect(result.relevantExternalTestPaths.some(p => p.includes("test_deployments.py"))).toBe(false);
737
+ });
738
+ it("Step 3 does not run when changedResources is empty (no API changes)", async () => {
739
+ writeFile("tests/server/orchestration/api/test_deployments.py", TEST_DEPLOYMENTS_CONTENT);
740
+ const result = await service.discoverTests(tmpDir, {
741
+ changedResources: [],
742
+ });
743
+ // changedResources=[] means PR mode with no endpoints — external tests excluded entirely
744
+ expect(result.relevantExternalTestPaths).toHaveLength(0);
745
+ });
746
+ it("Step 1 path-token scoring finds test_work_queues.py for changedResources=[work_queues]", async () => {
747
+ // Pattern Q scenario: work_queues.py changed → changedResources=["work_queues"]
748
+ // test_work_queues.py scores >0 by name (has "work_queues" token)
749
+ writeFile("tests/server/orchestration/api/test_work_queues.py", 'import pytest\nfrom prefect.server.api import work_queues\n\nasync def test_read_work_queue_status(client, work_queue):\n r = await client.get(f"/api/work_queues/{work_queue.id}/status")\n assert r.status_code == 200\n');
750
+ writeFile("tests/server/orchestration/api/test_flows.py", "import pytest\nfrom prefect.server import schemas\n\nasync def test_create_flow(client): pass\n");
751
+ const result = await service.discoverTests(tmpDir, {
752
+ changedResources: ["work_queues"],
753
+ });
754
+ const promoted = result.relevantExternalTestPaths;
755
+ // test_work_queues.py → Step 1 name-scores it → in relevantExternal before Step 3
756
+ expect(promoted.some(p => p.includes("test_work_queues.py"))).toBe(true);
757
+ // test_flows.py → scores 0 → not promoted via Step 1
758
+ // (may appear in Step 3 fallback but is not guaranteed)
404
759
  });
405
760
  });
406
761
  });
@@ -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.29";
2
+ export declare const EXECUTOR_DOCKER_IMAGE = "skyramp/executor:v1.3.32";
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[];
@@ -84,14 +84,14 @@ function setupVideoCapture(options, workspacePath, containerMountPath) {
84
84
  };
85
85
  if (!browserTest)
86
86
  return setup;
87
- // Per-execution video output directory on host (cleared so a retry never
88
- // reports a stale recording from a prior run).
89
- const videoSubdir = getVideoSubdir(options.testFile);
87
+ // Each execution gets a unique video directory so there is nothing to clean up
88
+ // between runs. Avoids EACCES when the Docker executor (which runs as root) has
89
+ // left root-owned files in a previous run's directory. The timestamp keeps the
90
+ // name readable/ordered; the random suffix guarantees uniqueness even when two
91
+ // executions land in the same millisecond (retries, parallel runs).
92
+ const videoSubdir = `${getVideoSubdir(options.testFile)}-${Date.now()}-${crypto.randomBytes(3).toString("hex")}`;
90
93
  setup.videoHostDir = path.join(workspacePath, ".skyramp", "videos", videoSubdir);
91
94
  setup.videoContainerDir = path.join(containerMountPath, ".skyramp", "videos", videoSubdir);
92
- if (fs.existsSync(setup.videoHostDir)) {
93
- fs.rmSync(setup.videoHostDir, { recursive: true, force: true });
94
- }
95
95
  fs.mkdirSync(setup.videoHostDir, { recursive: true });
96
96
  // TypeScript/JavaScript: generate a per-execution Playwright config with video.
97
97
  if (options.language === "typescript" || options.language === "javascript") {
@@ -440,7 +440,7 @@ export class TestExecutionService {
440
440
  try {
441
441
  fs.accessSync(workspacePath, fs.constants.R_OK);
442
442
  }
443
- catch (err) {
443
+ catch {
444
444
  throw new Error(`Workspace path does not exist or is not readable: ${workspacePath}`);
445
445
  }
446
446
  // Validate test file - use basename for safer filename extraction
@@ -521,7 +521,10 @@ describe("TestExecutionService.executeTest - Video capture for browser tests", (
521
521
  });
522
522
  const dockerOptions = mockRun.mock.calls[0][3];
523
523
  const videoSubdir = getVideoSubdir("/workspace/tests/login.spec.ts");
524
- const videoMount = dockerOptions.HostConfig.Mounts.find((m) => m.Target === `/home/user/.skyramp/videos/${videoSubdir}`);
524
+ // Each execution gets a unique per-run dir: `${videoSubdir}-<timestamp>`,
525
+ // so match by prefix rather than exact equality.
526
+ const videoMountPrefix = `/home/user/.skyramp/videos/${videoSubdir}`;
527
+ const videoMount = dockerOptions.HostConfig.Mounts.find((m) => m.Target.startsWith(videoMountPrefix));
525
528
  expect(videoMount).toBeDefined();
526
529
  expect(videoMount.Type).toBe("bind");
527
530
  expect(videoMount.ReadOnly).toBe(false);
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import path from "path";
2
3
  import { stripVTControlCharacters } from "util";
3
4
  import { TestExecutionService } from "../services/TestExecutionService.js";
4
5
  import { AnalyticsService } from "../services/AnalyticsService.js";
@@ -7,43 +8,17 @@ import { getWorkspaceBaseUrl } from "../utils/workspaceAuth.js";
7
8
  import { ProgrammingLanguage, TestType } from "../types/TestTypes.js";
8
9
  import { StateManager } from "../utils/AnalysisStateManager.js";
9
10
  import { logger } from "../utils/logger.js";
11
+ import { toolError } from "../utils/utils.js";
10
12
  const TOOL_NAME = "skyramp_execute_test";
11
13
  export function registerExecuteSkyrampTestTool(server) {
12
14
  server.registerTool(TOOL_NAME, {
13
- description: `Execute a Skyramp-generated test in isolated containerized environments for reliable, deterministic testing.
14
-
15
- Skyramp is a comprehensive testing platform that generates reliable, out-of-the-box functional and performance tests with AI-powered scaffolding. The test execution engine runs your generated tests in controlled, isolated environments to ensure consistent results.
16
-
17
- KEY FEATURES:
18
- • Isolated Execution: Tests run in containerized environments for consistency
19
- • Multi-Language Support: Execute tests written in Python, Java, JavaScript, or TypeScript
20
- • Out-of-the-Box Execution: Generated tests work immediately without modification
21
- • StateFile Integration: Optionally write execution results back to stateFile for health analysis
22
-
23
- REQUIRED PARAMETERS:
24
- - language: Programming language of your test file (python, javascript, typescript, java)
25
- - testType: Type of test to execute (smoke, contract, fuzz, integration, load, ui, e2e)
26
- - testFile: Absolute path to the generated test file to execute
27
- - token: Authentication token for your service (use empty string if no authentication required)
28
-
29
- OPTIONAL PARAMETERS:
30
- - stateFile: Path to state file from skyramp_analyze_changes. When provided, execution results (passed/failed, errors, duration) will be written back to enrich test health analysis.
31
-
32
- AUTHENTICATION:
33
- Pass an empty string as the token parameter — the server automatically injects SKYRAMP_TEST_TOKEN from the environment. The executor applies it with the correct header defined in workspace.yml:
34
- - bearer / oauth / token (or custom via authScheme) → Authorization: <scheme> <token>
35
- - cookie / session → Cookie: <token>
36
- - apiKey → <authHeader>: <token> (header name from workspace.yml)
37
- - basic → Authorization: Basic <token>
38
- Use an empty string only if the endpoint is confirmed unauthenticated.
39
- IMPORTANT NOTES:
40
- - First-time usage may take longer as Docker images are downloaded
41
- - Tests run in isolated containers for maximum reliability
42
- - Generated tests are designed to work out-of-the-box without modification
43
- - Results include detailed execution logs and test outcomes
44
- - When stateFile is provided, results are merged back for use by skyramp_actions
45
-
46
- For detailed documentation visit: https://www.skyramp.dev/docs/quickstart`,
15
+ description: `Execute a Skyramp-generated test in isolated containerized environments for reliable, deterministic testing. Call this once a test file exists on disk (from a skyramp_*_test_generation tool). First-time execution may take longer while Docker images download — this is expected, not a failure.`,
16
+ annotations: {
17
+ readOnlyHint: false,
18
+ destructiveHint: false,
19
+ idempotentHint: false,
20
+ openWorldHint: true,
21
+ },
47
22
  inputSchema: {
48
23
  workspacePath: z
49
24
  .string()
@@ -53,13 +28,18 @@ For detailed documentation visit: https://www.skyramp.dev/docs/quickstart`,
53
28
  .describe("Programming language of the test file to execute (e.g., python, javascript, typescript, java)"),
54
29
  testType: z
55
30
  .nativeEnum(TestType)
56
- .describe("Type of the test to execute."),
31
+ .describe("Type of the test to execute (e.g., smoke, contract, fuzz, integration, load, ui, e2e)."),
57
32
  testFile: z
58
33
  .string()
59
- .describe("ALWAYS USE ABSOLUTE PATH to the test file to execute"),
34
+ .describe("Absolute path to the test file to execute."),
60
35
  token: z
61
36
  .string()
62
- .describe("Skyramp authentication token for test execution. USE EMPTY STRING WHEN USER CONFIRMS 'No token required'"),
37
+ .optional()
38
+ .describe("Explicit Skyramp authentication token for test execution. Omit this parameter to use SKYRAMP_TEST_TOKEN from the environment. An empty string is passed through as a literal token value, not a 'no auth' signal — use `unauthenticated: true` for tests that must run without credentials."),
39
+ unauthenticated: z
40
+ .boolean()
41
+ .optional()
42
+ .describe("Set true to force this test execution to carry NO auth token, even if SKYRAMP_TEST_TOKEN is set in the environment or a token was passed. Use for tests that must assert 401/403 unauthenticated behavior."),
63
43
  playwrightSaveStoragePath: z
64
44
  .string()
65
45
  .optional()
@@ -106,6 +86,10 @@ For detailed documentation visit: https://www.skyramp.dev/docs/quickstart`,
106
86
  let didSetSkyrampBaseUrl = false;
107
87
  let useHostNetwork = false;
108
88
  try {
89
+ if (!path.isAbsolute(params.testFile)) {
90
+ errorResult = toolError(`testFile must be an absolute path, got: ${params.testFile}`);
91
+ return errorResult;
92
+ }
109
93
  // Send initial progress
110
94
  await sendProgress(0, 100, "Starting test execution...");
111
95
  // Always resolve workspace config for dockerNetwork (host networking)
@@ -119,25 +103,25 @@ For detailed documentation visit: https://www.skyramp.dev/docs/quickstart`,
119
103
  didSetSkyrampBaseUrl = true;
120
104
  }
121
105
  else if (candidates.length > 0) {
122
- return {
123
- content: [{
124
- type: "text",
125
- text: [
126
- `Cannot determine SKYRAMP_TEST_BASE_URL test file matches multiple services:`,
127
- ...candidates.map((c) => ` • ${c.serviceName}: ${c.baseUrl}`),
128
- ``,
129
- `Re-invoke with SKYRAMP_TEST_BASE_URL set to the correct service URL, or make each service's testDirectory unique in .skyramp/workspace.yml.`,
130
- ].join("\n"),
131
- }],
132
- isError: true,
133
- };
106
+ errorResult = toolError([
107
+ `Cannot determine SKYRAMP_TEST_BASE_URL — test file matches multiple services:`,
108
+ ...candidates.map((c) => ` • ${c.serviceName}: ${c.baseUrl}`),
109
+ ``,
110
+ `Re-invoke with SKYRAMP_TEST_BASE_URL set to the correct service URL, or make each service's testDirectory unique in .skyramp/workspace.yml.`,
111
+ ].join("\n"));
112
+ return errorResult;
134
113
  }
135
114
  }
136
115
  }
137
116
  const executionService = new TestExecutionService();
138
- // Fall back to SKYRAMP_TEST_TOKEN when caller passes empty string or omits the token.
139
- // This keeps secrets server-side and avoids embedding credential values in prompts.
140
- const effectiveToken = params.token || process.env.SKYRAMP_TEST_TOKEN || "";
117
+ // `unauthenticated: true` forces no auth token regardless of what's passed or
118
+ // configured, so tests asserting 401/403 unauthenticated behavior can't be broken
119
+ // by an ambient SKYRAMP_TEST_TOKEN — see SKYR-3874. Otherwise fall back to the
120
+ // environment token when the caller omits `token`; this keeps secrets server-side
121
+ // and avoids embedding credential values in prompts.
122
+ const effectiveToken = params.unauthenticated
123
+ ? ""
124
+ : (params.token ?? process.env.SKYRAMP_TEST_TOKEN ?? "");
141
125
  // Execute test with progress callback - reports Docker cache/pull status
142
126
  const result = await executionService.executeTest({
143
127
  testFile: params.testFile,
@@ -180,16 +164,9 @@ For detailed documentation visit: https://www.skyramp.dev/docs/quickstart`,
180
164
  // Progress is already reported by TestExecutionService
181
165
  // Only report final status if not already at 100%
182
166
  if (result.status !== TestExecutionStatus.Pass) {
183
- errorResult = {
184
- content: [
185
- {
186
- type: "text",
187
- text: `Test execution failed: ${stripVTControlCharacters(result.output || "")}
188
- **IMPORTANT: IF THE EXECUTION FAILED BECAUSE OF A 401 STATUS CODE, ASK THE USER TO PROVIDE THE AUTHENTICATION TOKEN TO THE TOOL OR A PLACE FROM WHERE WE CAN READ THE TOKEN. DO NOT UPDATE THE GENERATED TEST FILE.**`,
189
- },
190
- ],
191
- isError: true,
192
- };
167
+ errorResult = toolError(`Test execution failed: ${stripVTControlCharacters(result.output || "")}
168
+
169
+ If this failed with a 401 status code, ask the user for the authentication token (or where to read it from) and retry — leave the generated test file unchanged.`);
193
170
  return errorResult;
194
171
  }
195
172
  // Success - progress already reported by TestExecutionService
@@ -206,15 +183,7 @@ For detailed documentation visit: https://www.skyramp.dev/docs/quickstart`,
206
183
  };
207
184
  }
208
185
  catch (err) {
209
- errorResult = {
210
- content: [
211
- {
212
- type: "text",
213
- text: `Test execution failed: ${err.message}`,
214
- },
215
- ],
216
- isError: true,
217
- };
186
+ errorResult = toolError(`Test execution failed: ${err.message}`);
218
187
  return errorResult;
219
188
  }
220
189
  finally {