@skyramp/mcp 0.0.64-rc.8 → 0.0.64
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/index.js +2 -0
- package/build/playwright/registerPlaywrightTools.js +1 -1
- package/build/playwright/traceRecordingPrompt.js +9 -3
- package/build/prompts/test-maintenance/drift-analysis-prompt.js +26 -7
- package/build/prompts/test-maintenance/driftAnalysisSections.js +96 -34
- package/build/prompts/test-maintenance/enhanceAssertionSection.js +99 -0
- package/build/prompts/test-recommendation/recommendationSections.js +24 -9
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +96 -27
- package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +239 -2
- package/build/prompts/testbot/testbot-prompts.js +185 -120
- package/build/services/TestDiscoveryService.js +23 -0
- package/build/services/TestExecutionService.js +1 -1
- package/build/services/TestGenerationService.js +83 -12
- package/build/services/TestGenerationService.test.js +111 -2
- package/build/tool-phase-coverage.test.js +8 -2
- package/build/tool-phases.js +11 -13
- package/build/tools/generate-tests/generateBatchScenarioRestTool.js +203 -0
- package/build/tools/generate-tests/generateContractRestTool.js +3 -73
- package/build/tools/generate-tests/generateIntegrationRestTool.js +11 -61
- package/build/tools/submitReportTool.js +11 -3
- package/build/tools/submitReportTool.test.js +1 -1
- package/build/tools/test-management/analyzeChangesTool.js +14 -4
- package/build/types/RepositoryAnalysis.js +1 -0
- package/build/utils/scenarioDrafting.js +121 -11
- package/build/utils/scenarioDrafting.test.js +266 -3
- package/node_modules/playwright/ThirdPartyNotices.txt +679 -3093
- package/node_modules/playwright/lib/mcp/skyramp/assertTool.js +52 -0
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +290 -15
- package/node_modules/playwright/lib/mcp/test/skyRampExport.js +60 -0
- package/package.json +2 -2
- package/build/tools/test-recommendation/recommendTestsTool.js +0 -274
|
@@ -8,7 +8,7 @@ import { parseWorkspaceAuthType } from "../../utils/workspaceAuth.js";
|
|
|
8
8
|
import { AnalyticsService } from "../../services/AnalyticsService.js";
|
|
9
9
|
import { StateManager, registerSession, storeSessionData, } from "../../utils/AnalysisStateManager.js";
|
|
10
10
|
import { buildRecommendationPrompt } from "../../prompts/test-recommendation/test-recommendation-prompt.js";
|
|
11
|
-
import { MAX_RECOMMENDATIONS } from "../../prompts/test-recommendation/recommendationSections.js";
|
|
11
|
+
import { MAX_RECOMMENDATIONS, MAX_TESTS_TO_GENERATE } from "../../prompts/test-recommendation/recommendationSections.js";
|
|
12
12
|
import { WorkspaceConfigManager } from "@skyramp/skyramp";
|
|
13
13
|
import { TestDiscoveryService } from "../../services/TestDiscoveryService.js";
|
|
14
14
|
import { computeBranchDiff } from "../../utils/branchDiff.js";
|
|
@@ -172,6 +172,12 @@ const analyzeChangesSchema = {
|
|
|
172
172
|
.default(MAX_RECOMMENDATIONS)
|
|
173
173
|
.optional()
|
|
174
174
|
.describe(`Number of ranked test recommendations to generate. Defaults to ${MAX_RECOMMENDATIONS}.`),
|
|
175
|
+
maxGenerate: z
|
|
176
|
+
.number()
|
|
177
|
+
.int()
|
|
178
|
+
.min(0)
|
|
179
|
+
.optional()
|
|
180
|
+
.describe(`Number of tests to generate and execute. Defaults to ${MAX_TESTS_TO_GENERATE} (diff mode) or all recommendations (full repo).`),
|
|
175
181
|
prNumber: z
|
|
176
182
|
.number()
|
|
177
183
|
.optional()
|
|
@@ -526,12 +532,16 @@ to produce a unified state file for the test health workflow.
|
|
|
526
532
|
// ── Step 10: Build full RepositoryAnalysis for ranked recommendations ──
|
|
527
533
|
const sessionId = crypto.randomUUID();
|
|
528
534
|
// Build existing test locations map (type → file list) for deduplication in recommendations
|
|
535
|
+
// Include covered endpoints so the agent can cross-check resource paths before creating new files.
|
|
529
536
|
const testLocationsByType = {};
|
|
530
537
|
for (const t of existingTests) {
|
|
531
538
|
const type = t.testType || "unknown";
|
|
532
|
-
|
|
533
|
-
? `${
|
|
539
|
+
const entry = t.apiEndpoint
|
|
540
|
+
? `${t.testFile} (covers: ${t.apiEndpoint})`
|
|
534
541
|
: t.testFile;
|
|
542
|
+
testLocationsByType[type] = testLocationsByType[type]
|
|
543
|
+
? `${testLocationsByType[type]}, ${entry}`
|
|
544
|
+
: entry;
|
|
535
545
|
}
|
|
536
546
|
// Build the full RepositoryAnalysis object — same structure as analyzeRepositoryTool
|
|
537
547
|
// so buildRecommendationPrompt can reason over enriched endpoint + scenario data
|
|
@@ -700,7 +710,7 @@ to produce a unified state file for the test health workflow.
|
|
|
700
710
|
}
|
|
701
711
|
}
|
|
702
712
|
}
|
|
703
|
-
const recommendationPrompt = buildRecommendationPrompt(fullAnalysis, analysisScope, topN, prContext, wsAuthHeader, wsAuthType);
|
|
713
|
+
const recommendationPrompt = buildRecommendationPrompt(fullAnalysis, analysisScope, topN, prContext, wsAuthHeader, wsAuthType, params.maxGenerate);
|
|
704
714
|
const routerMountContext = grepRouterMountingContext(params.repositoryPath);
|
|
705
715
|
await sendProgress(100, 100, "Analysis complete.");
|
|
706
716
|
const stateSize = await stateManager.getSizeFormatted();
|
|
@@ -71,6 +71,7 @@ export const scenarioStepSchema = z.object({
|
|
|
71
71
|
chainsFrom: z
|
|
72
72
|
.union([chainingRefSchema, z.array(chainingRefSchema)])
|
|
73
73
|
.optional(),
|
|
74
|
+
bodyMustInclude: z.array(z.string()).optional(),
|
|
74
75
|
});
|
|
75
76
|
export const draftedScenarioSchema = z.object({
|
|
76
77
|
scenarioName: z.string(),
|
|
@@ -573,6 +573,66 @@ function diffDirectValidation(method, path, resource) {
|
|
|
573
573
|
testType: "contract",
|
|
574
574
|
};
|
|
575
575
|
}
|
|
576
|
+
/**
|
|
577
|
+
* Draft a "mutation with collection modification" scenario for PUT/PATCH endpoints.
|
|
578
|
+
* This tests adding/removing child items (e.g., order line items) and verifying that
|
|
579
|
+
* derived totals (total_amount, item_count, subtotal) are recalculated.
|
|
580
|
+
* This pattern catches the most common class of user-reported bugs.
|
|
581
|
+
*/
|
|
582
|
+
function diffDirectMutationRecalc(method, resource, singular, group) {
|
|
583
|
+
const steps = [];
|
|
584
|
+
if (group.methods.has("POST")) {
|
|
585
|
+
steps.push({
|
|
586
|
+
order: 1,
|
|
587
|
+
method: "POST",
|
|
588
|
+
path: group.basePath,
|
|
589
|
+
description: `Create a ${singular} with initial items`,
|
|
590
|
+
interactionType: "success",
|
|
591
|
+
expectedStatusCode: 201,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
const targetPath = group.paramPath ?? group.basePath;
|
|
595
|
+
const pathParamName = group.paramPath?.match(/\{([^}]+)\}/)?.[1] ?? `${singular}_id`;
|
|
596
|
+
const sourceStep = steps.length;
|
|
597
|
+
steps.push({
|
|
598
|
+
order: steps.length + 1,
|
|
599
|
+
method,
|
|
600
|
+
path: targetPath,
|
|
601
|
+
description: `${method} the ${singular} — add/replace items in the child collection (e.g. items array with product references chained from prior steps) and verify total_amount is recalculated`,
|
|
602
|
+
interactionType: "success",
|
|
603
|
+
expectedStatusCode: 200,
|
|
604
|
+
bodyMustInclude: ["child collection array (e.g. items)", "FK reference to parent resource (e.g. product_id)", "quantity or amount"],
|
|
605
|
+
...(sourceStep > 0 && group.paramPath
|
|
606
|
+
? { chainsFrom: { sourceStep, sourceField: "id", sourceLocation: "body", targetParam: pathParamName, targetLocation: "path" } }
|
|
607
|
+
: {}),
|
|
608
|
+
});
|
|
609
|
+
if (group.paramPath && group.methods.has("GET")) {
|
|
610
|
+
steps.push({
|
|
611
|
+
order: steps.length + 1,
|
|
612
|
+
method: "GET",
|
|
613
|
+
path: group.paramPath,
|
|
614
|
+
description: `Verify the ${singular} reflects the updated child collection with correct FK references, quantities, and recalculated totals`,
|
|
615
|
+
interactionType: "success",
|
|
616
|
+
expectedStatusCode: 200,
|
|
617
|
+
expectedResponseFields: ["child collection array", "each child's FK reference", "each child's quantity", "recalculated total"],
|
|
618
|
+
...(sourceStep > 0 && group.paramPath
|
|
619
|
+
? { chainsFrom: { sourceStep, sourceField: "id", sourceLocation: "body", targetParam: pathParamName, targetLocation: "path" } }
|
|
620
|
+
: {}),
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
return {
|
|
624
|
+
scenarioName: `${resource}-${method.toLowerCase()}-add-items-recalculate`,
|
|
625
|
+
description: `Mutation recalculation: ${method} ${targetPath} — modify child collection and verify derived totals are recomputed`,
|
|
626
|
+
category: "new_endpoint",
|
|
627
|
+
priority: "high",
|
|
628
|
+
steps,
|
|
629
|
+
chainingKeys: ["id", pathParamName],
|
|
630
|
+
requiresAuth: true,
|
|
631
|
+
estimatedComplexity: "complex",
|
|
632
|
+
source: "code-inferred",
|
|
633
|
+
testType: "integration",
|
|
634
|
+
};
|
|
635
|
+
}
|
|
576
636
|
/**
|
|
577
637
|
* Draft scenarios that directly test each new endpoint in the branch diff.
|
|
578
638
|
*
|
|
@@ -597,15 +657,64 @@ export function draftDiffDirectScenarios(newEndpoints, resourceGroups) {
|
|
|
597
657
|
methods.add(ep.method.toUpperCase());
|
|
598
658
|
grouped.set(ep.path, methods);
|
|
599
659
|
}
|
|
660
|
+
// Segment-boundary suffix match: avoids "/orders" matching "/preorders".
|
|
661
|
+
const pathSuffixMatches = (fullPath, suffix) => {
|
|
662
|
+
if (fullPath === suffix)
|
|
663
|
+
return true;
|
|
664
|
+
const fullSegs = fullPath.split("/").filter(Boolean);
|
|
665
|
+
const suffixSegs = suffix.split("/").filter(Boolean);
|
|
666
|
+
if (suffixSegs.length === 0 || suffixSegs.length > fullSegs.length)
|
|
667
|
+
return false;
|
|
668
|
+
const offset = fullSegs.length - suffixSegs.length;
|
|
669
|
+
for (let i = 0; i < suffixSegs.length; i++) {
|
|
670
|
+
if (fullSegs[offset + i] !== suffixSegs[i])
|
|
671
|
+
return false;
|
|
672
|
+
}
|
|
673
|
+
return true;
|
|
674
|
+
};
|
|
600
675
|
for (const [epPath, methods] of grouped) {
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
676
|
+
let resource = extractResourceName(epPath);
|
|
677
|
+
let resolvedPath = epPath;
|
|
678
|
+
// When resource was found but epPath may be router-relative (e.g. "/orders"
|
|
679
|
+
// instead of "/api/v1/orders"), try to upgrade resolvedPath to the full path.
|
|
680
|
+
if (resource) {
|
|
681
|
+
const existingGroup = resourceGroups.get(resource);
|
|
682
|
+
if (existingGroup) {
|
|
683
|
+
if (existingGroup.paramPath && existingGroup.paramPath !== epPath && pathSuffixMatches(existingGroup.paramPath, epPath)) {
|
|
684
|
+
resolvedPath = existingGroup.paramPath;
|
|
685
|
+
}
|
|
686
|
+
else if (existingGroup.basePath !== epPath && pathSuffixMatches(existingGroup.basePath, epPath)) {
|
|
687
|
+
resolvedPath = existingGroup.basePath;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
// When extractResourceName returns null (e.g. "/{order_id}"), fall back to
|
|
692
|
+
// matching the path suffix against known resourceGroup paths using segment
|
|
693
|
+
// boundaries. Prefer the longest (most specific) match.
|
|
694
|
+
if (!resource) {
|
|
695
|
+
let bestMatch = null;
|
|
696
|
+
for (const [rName, rGroup] of resourceGroups) {
|
|
697
|
+
if (rGroup.paramPath && pathSuffixMatches(rGroup.paramPath, epPath)) {
|
|
698
|
+
if (!bestMatch || rGroup.paramPath.length > bestMatch.length) {
|
|
699
|
+
bestMatch = { name: rName, path: rGroup.paramPath, length: rGroup.paramPath.length };
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
if (pathSuffixMatches(rGroup.basePath, epPath)) {
|
|
703
|
+
if (!bestMatch || rGroup.basePath.length > bestMatch.length) {
|
|
704
|
+
bestMatch = { name: rName, path: rGroup.basePath, length: rGroup.basePath.length };
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
if (!bestMatch)
|
|
709
|
+
continue;
|
|
710
|
+
resource = bestMatch.name;
|
|
711
|
+
resolvedPath = bestMatch.path;
|
|
712
|
+
}
|
|
604
713
|
const group = resourceGroups.get(resource);
|
|
605
714
|
if (!group)
|
|
606
715
|
continue;
|
|
607
716
|
const singular = singularize(resource);
|
|
608
|
-
const hasPathParam =
|
|
717
|
+
const hasPathParam = resolvedPath.includes("{");
|
|
609
718
|
const add = (s) => {
|
|
610
719
|
if (!seen.has(s.scenarioName)) {
|
|
611
720
|
seen.add(s.scenarioName);
|
|
@@ -614,23 +723,24 @@ export function draftDiffDirectScenarios(newEndpoints, resourceGroups) {
|
|
|
614
723
|
};
|
|
615
724
|
for (const method of methods) {
|
|
616
725
|
if (method === "PUT" || method === "PATCH") {
|
|
726
|
+
add(diffDirectMutationRecalc(method, resource, singular, group));
|
|
617
727
|
add(diffDirectIntegration(method, resource, singular, group));
|
|
618
|
-
add(diffDirectContract(method,
|
|
728
|
+
add(diffDirectContract(method, resolvedPath, resource));
|
|
619
729
|
if (hasPathParam)
|
|
620
|
-
add(diffDirectNotFound(method,
|
|
730
|
+
add(diffDirectNotFound(method, resolvedPath, resource, singular));
|
|
621
731
|
}
|
|
622
732
|
else if (method === "POST") {
|
|
623
733
|
add(diffDirectIntegration(method, resource, singular, group));
|
|
624
|
-
add(diffDirectContract(method,
|
|
625
|
-
add(diffDirectValidation(method,
|
|
734
|
+
add(diffDirectContract(method, resolvedPath, resource));
|
|
735
|
+
add(diffDirectValidation(method, resolvedPath, resource));
|
|
626
736
|
}
|
|
627
737
|
else if (method === "DELETE") {
|
|
628
738
|
add(diffDirectIntegration(method, resource, singular, group));
|
|
629
|
-
add(diffDirectContract(method,
|
|
739
|
+
add(diffDirectContract(method, resolvedPath, resource));
|
|
630
740
|
}
|
|
631
741
|
else if (method === "GET" && hasPathParam) {
|
|
632
|
-
add(diffDirectContract(method,
|
|
633
|
-
add(diffDirectNotFound(method,
|
|
742
|
+
add(diffDirectContract(method, resolvedPath, resource));
|
|
743
|
+
add(diffDirectNotFound(method, resolvedPath, resource, singular));
|
|
634
744
|
}
|
|
635
745
|
}
|
|
636
746
|
}
|
|
@@ -317,12 +317,17 @@ describe("draftDiffDirectScenarios", () => {
|
|
|
317
317
|
orders: { basePath: "/api/orders", methods: ["GET", "POST", "PUT"], paramPath: "/api/orders/{order_id}" },
|
|
318
318
|
});
|
|
319
319
|
const scenarios = draftDiffDirectScenarios([{ method: "PUT", path: "/api/orders/{order_id}" }], groups);
|
|
320
|
-
expect(scenarios.length).toBeGreaterThanOrEqual(
|
|
320
|
+
expect(scenarios.length).toBeGreaterThanOrEqual(3);
|
|
321
321
|
for (const s of scenarios) {
|
|
322
322
|
expect(s.category).toBe("new_endpoint");
|
|
323
323
|
}
|
|
324
|
+
// Mutation-recalculation scenario is drafted first for PUT/PATCH
|
|
325
|
+
const mutationRecalc = scenarios.find(s => s.testType === "integration" && s.scenarioName.includes("add-items-recalculate"));
|
|
326
|
+
expect(mutationRecalc).toBeDefined();
|
|
327
|
+
expect(mutationRecalc.steps.some(st => st.method === "PUT")).toBe(true);
|
|
328
|
+
expect(mutationRecalc.description).toContain("recalculation");
|
|
324
329
|
// Integration happy path includes the PUT step
|
|
325
|
-
const integration = scenarios.find(s => s.testType === "integration");
|
|
330
|
+
const integration = scenarios.find(s => s.testType === "integration" && s.scenarioName.includes("happy-path"));
|
|
326
331
|
expect(integration).toBeDefined();
|
|
327
332
|
expect(integration.steps.some(st => st.method === "PUT")).toBe(true);
|
|
328
333
|
// Description prompts LLM to discover prerequisites from source code
|
|
@@ -334,12 +339,40 @@ describe("draftDiffDirectScenarios", () => {
|
|
|
334
339
|
const notFound = scenarios.find(s => s.steps.some(st => st.expectedStatusCode === 404));
|
|
335
340
|
expect(notFound).toBeDefined();
|
|
336
341
|
});
|
|
342
|
+
it("generates mutation-recalc scenario for a new PATCH endpoint", () => {
|
|
343
|
+
const groups = makeGroups({
|
|
344
|
+
orders: { basePath: "/api/orders", methods: ["GET", "POST", "PATCH"], paramPath: "/api/orders/{order_id}" },
|
|
345
|
+
});
|
|
346
|
+
const scenarios = draftDiffDirectScenarios([{ method: "PATCH", path: "/api/orders/{order_id}" }], groups);
|
|
347
|
+
// Mutation-recalculation scenario is drafted for PATCH
|
|
348
|
+
const mutationRecalc = scenarios.find(s => s.scenarioName === "orders-patch-add-items-recalculate");
|
|
349
|
+
expect(mutationRecalc).toBeDefined();
|
|
350
|
+
expect(mutationRecalc.testType).toBe("integration");
|
|
351
|
+
expect(mutationRecalc.category).toBe("new_endpoint");
|
|
352
|
+
expect(mutationRecalc.priority).toBe("high");
|
|
353
|
+
expect(mutationRecalc.description).toContain("recalculation");
|
|
354
|
+
expect(mutationRecalc.description).toContain("derived totals");
|
|
355
|
+
// Should have 3 steps: POST (create) → PATCH (add items) → GET (verify)
|
|
356
|
+
expect(mutationRecalc.steps).toHaveLength(3);
|
|
357
|
+
expect(mutationRecalc.steps[0].method).toBe("POST");
|
|
358
|
+
expect(mutationRecalc.steps[1].method).toBe("PATCH");
|
|
359
|
+
expect(mutationRecalc.steps[2].method).toBe("GET");
|
|
360
|
+
// Happy-path integration scenario is also still present
|
|
361
|
+
const happyPath = scenarios.find(s => s.scenarioName.includes("happy-path") && s.testType === "integration");
|
|
362
|
+
expect(happyPath).toBeDefined();
|
|
363
|
+
});
|
|
337
364
|
it("integration scenario minimum steps: POST resource then PUT — LLM discovers prereqs from source code", () => {
|
|
338
365
|
const groups = makeGroups({
|
|
339
366
|
orders: { basePath: "/api/orders", methods: ["GET", "POST", "PUT"], paramPath: "/api/orders/{order_id}" },
|
|
340
367
|
});
|
|
341
368
|
const scenarios = draftDiffDirectScenarios([{ method: "PUT", path: "/api/orders/{order_id}" }], groups);
|
|
342
|
-
|
|
369
|
+
// Mutation-recalculation scenario comes first
|
|
370
|
+
const mutationRecalc = scenarios.find(s => s.testType === "integration" && s.scenarioName.includes("add-items-recalculate"));
|
|
371
|
+
expect(mutationRecalc).toBeDefined();
|
|
372
|
+
expect(mutationRecalc.steps.some(st => st.method === "POST" && st.path === "/api/orders")).toBe(true);
|
|
373
|
+
expect(mutationRecalc.steps.some(st => st.method === "PUT")).toBe(true);
|
|
374
|
+
// Happy-path integration scenario
|
|
375
|
+
const integration = scenarios.find(s => s.testType === "integration" && s.scenarioName.includes("happy-path"));
|
|
343
376
|
expect(integration).toBeDefined();
|
|
344
377
|
// Minimum steps: create the resource + call the new endpoint
|
|
345
378
|
expect(integration.steps.some(st => st.method === "POST" && st.path === "/api/orders")).toBe(true);
|
|
@@ -378,4 +411,234 @@ describe("draftDiffDirectScenarios", () => {
|
|
|
378
411
|
expect(diffDirect.length).toBeGreaterThan(0);
|
|
379
412
|
expect(diffDirect.some(s => s.steps.some(st => st.method === "PUT"))).toBe(true);
|
|
380
413
|
});
|
|
414
|
+
it("resolves router-relative param paths (e.g. /{order_id}) against known resourceGroups", () => {
|
|
415
|
+
const groups = makeGroups({
|
|
416
|
+
orders: { basePath: "/api/v1/orders", methods: ["GET", "POST", "PATCH", "DELETE"], paramPath: "/api/v1/orders/{order_id}" },
|
|
417
|
+
products: { basePath: "/api/v1/products", methods: ["GET", "POST"], paramPath: "/api/v1/products/{id}" },
|
|
418
|
+
});
|
|
419
|
+
// Diff scanner reports router-relative path, not the full API path
|
|
420
|
+
const scenarios = draftDiffDirectScenarios([{ method: "PATCH", path: "/{order_id}" }], groups);
|
|
421
|
+
expect(scenarios.length).toBeGreaterThanOrEqual(3);
|
|
422
|
+
// Mutation-recalculation scenario is drafted
|
|
423
|
+
const mutationRecalc = scenarios.find(s => s.scenarioName === "orders-patch-add-items-recalculate");
|
|
424
|
+
expect(mutationRecalc).toBeDefined();
|
|
425
|
+
expect(mutationRecalc.testType).toBe("integration");
|
|
426
|
+
expect(mutationRecalc.category).toBe("new_endpoint");
|
|
427
|
+
expect(mutationRecalc.steps[1].method).toBe("PATCH");
|
|
428
|
+
// Contract and not-found scenarios use the resolved full path
|
|
429
|
+
const contract = scenarios.find(s => s.scenarioName.includes("contract"));
|
|
430
|
+
expect(contract).toBeDefined();
|
|
431
|
+
expect(contract.steps[0].path).toBe("/api/v1/orders/{order_id}");
|
|
432
|
+
const notFound = scenarios.find(s => s.scenarioName.includes("not-found"));
|
|
433
|
+
expect(notFound).toBeDefined();
|
|
434
|
+
expect(notFound.steps[0].path).toBe("/api/v1/orders/{order_id}");
|
|
435
|
+
});
|
|
436
|
+
it("resolves router-relative base paths (e.g. /orders) against known resourceGroups", () => {
|
|
437
|
+
const groups = makeGroups({
|
|
438
|
+
orders: { basePath: "/api/v1/orders", methods: ["GET", "POST"], paramPath: "/api/v1/orders/{order_id}" },
|
|
439
|
+
});
|
|
440
|
+
const scenarios = draftDiffDirectScenarios([{ method: "POST", path: "/orders" }], groups);
|
|
441
|
+
expect(scenarios.length).toBeGreaterThanOrEqual(2);
|
|
442
|
+
const integration = scenarios.find(s => s.testType === "integration");
|
|
443
|
+
expect(integration).toBeDefined();
|
|
444
|
+
expect(integration.category).toBe("new_endpoint");
|
|
445
|
+
});
|
|
446
|
+
it("skips unresolvable router-relative paths", () => {
|
|
447
|
+
const groups = makeGroups({
|
|
448
|
+
products: { basePath: "/api/v1/products", methods: ["GET", "POST"], paramPath: "/api/v1/products/{id}" },
|
|
449
|
+
});
|
|
450
|
+
// /{nonexistent_id} doesn't match any known resource
|
|
451
|
+
const scenarios = draftDiffDirectScenarios([{ method: "PATCH", path: "/{nonexistent_id}" }], groups);
|
|
452
|
+
expect(scenarios).toEqual([]);
|
|
453
|
+
});
|
|
454
|
+
it("PR #195 regression: PATCH /{order_id} produces mutation-recalculate scenario in GENERATE slots", () => {
|
|
455
|
+
// Simulates the exact data from PR #195:
|
|
456
|
+
// - Full endpoint list with orders, products, reviews
|
|
457
|
+
// - newEndpoints has PATCH /{order_id} (router-relative)
|
|
458
|
+
const endpoints = [
|
|
459
|
+
{ path: "/api/v1/orders", methods: ["GET", "POST"] },
|
|
460
|
+
{ path: "/api/v1/orders/{order_id}", methods: ["GET", "PATCH", "DELETE"] },
|
|
461
|
+
{ path: "/api/v1/products", methods: ["GET", "POST"] },
|
|
462
|
+
{ path: "/api/v1/products/{product_id}", methods: ["GET", "PUT", "DELETE"] },
|
|
463
|
+
{ path: "/api/v1/reviews", methods: ["GET", "POST"] },
|
|
464
|
+
{ path: "/api/v1/reset", methods: ["POST"] },
|
|
465
|
+
];
|
|
466
|
+
const newEndpoints = [{ method: "PATCH", path: "/{order_id}" }];
|
|
467
|
+
const allScenarios = draftScenariosFromEndpoints(endpoints, newEndpoints);
|
|
468
|
+
// The diff-direct scenarios should include orders-patch-add-items-recalculate
|
|
469
|
+
const mutationRecalc = allScenarios.find(s => s.scenarioName === "orders-patch-add-items-recalculate");
|
|
470
|
+
expect(mutationRecalc).toBeDefined();
|
|
471
|
+
expect(mutationRecalc.category).toBe("new_endpoint"); // → CRITICAL priority tier
|
|
472
|
+
expect(mutationRecalc.steps).toHaveLength(3);
|
|
473
|
+
expect(mutationRecalc.steps[0].method).toBe("POST");
|
|
474
|
+
expect(mutationRecalc.steps[1].method).toBe("PATCH");
|
|
475
|
+
expect(mutationRecalc.steps[2].method).toBe("GET");
|
|
476
|
+
expect(mutationRecalc.description).toContain("derived totals");
|
|
477
|
+
// Verify it outranks all non-new_endpoint scenarios (cascade-delete, unique-constraint, etc.)
|
|
478
|
+
const newEndpointScenarios = allScenarios.filter(s => s.category === "new_endpoint");
|
|
479
|
+
const otherScenarios = allScenarios.filter(s => s.category !== "new_endpoint");
|
|
480
|
+
expect(newEndpointScenarios.length).toBeGreaterThanOrEqual(3);
|
|
481
|
+
// All new_endpoint scenarios should include our mutation-recalculate
|
|
482
|
+
expect(newEndpointScenarios.some(s => s.scenarioName === "orders-patch-add-items-recalculate")).toBe(true);
|
|
483
|
+
// Non-new_endpoint scenarios should NOT include mutation-recalculate
|
|
484
|
+
expect(otherScenarios.every(s => s.scenarioName !== "orders-patch-add-items-recalculate")).toBe(true);
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
describe("diffDirectMutationRecalc — diverse app types (no hardcoded field names)", () => {
|
|
488
|
+
const makeGroups = (defs) => new Map(Object.entries(defs).map(([k, v]) => [k, { ...v, methods: new Set(v.methods) }]));
|
|
489
|
+
const testCases = [
|
|
490
|
+
{
|
|
491
|
+
name: "E-commerce: PATCH /orders/{order_id}",
|
|
492
|
+
resource: "orders",
|
|
493
|
+
group: { basePath: "/api/v1/orders", methods: ["GET", "POST", "PATCH"], paramPath: "/api/v1/orders/{order_id}" },
|
|
494
|
+
method: "PATCH",
|
|
495
|
+
expectedParamName: "order_id",
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
name: "Project management: PUT /projects/{id}",
|
|
499
|
+
resource: "projects",
|
|
500
|
+
group: { basePath: "/api/projects", methods: ["GET", "POST", "PUT"], paramPath: "/api/projects/{id}" },
|
|
501
|
+
method: "PUT",
|
|
502
|
+
expectedParamName: "id",
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
name: "Invoicing: PATCH /invoices/{invoice_id}",
|
|
506
|
+
resource: "invoices",
|
|
507
|
+
group: { basePath: "/v2/invoices", methods: ["GET", "POST", "PATCH"], paramPath: "/v2/invoices/{invoice_id}" },
|
|
508
|
+
method: "PATCH",
|
|
509
|
+
expectedParamName: "invoice_id",
|
|
510
|
+
},
|
|
511
|
+
{
|
|
512
|
+
name: "Education LMS: PUT /courses/{course_id}",
|
|
513
|
+
resource: "courses",
|
|
514
|
+
group: { basePath: "/api/courses", methods: ["GET", "POST", "PUT"], paramPath: "/api/courses/{course_id}" },
|
|
515
|
+
method: "PUT",
|
|
516
|
+
expectedParamName: "course_id",
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
name: "Healthcare: PATCH /prescriptions/{prescription_id}",
|
|
520
|
+
resource: "prescriptions",
|
|
521
|
+
group: { basePath: "/api/prescriptions", methods: ["GET", "POST", "PATCH"], paramPath: "/api/prescriptions/{prescription_id}" },
|
|
522
|
+
method: "PATCH",
|
|
523
|
+
expectedParamName: "prescription_id",
|
|
524
|
+
},
|
|
525
|
+
{
|
|
526
|
+
name: "Logistics: PUT /shipments/{shipment_id}",
|
|
527
|
+
resource: "shipments",
|
|
528
|
+
group: { basePath: "/api/shipments", methods: ["GET", "POST", "PUT"], paramPath: "/api/shipments/{shipment_id}" },
|
|
529
|
+
method: "PUT",
|
|
530
|
+
expectedParamName: "shipment_id",
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
name: "Restaurant: PATCH /menus/{menuId}",
|
|
534
|
+
resource: "menus",
|
|
535
|
+
group: { basePath: "/api/menus", methods: ["GET", "POST", "PATCH"], paramPath: "/api/menus/{menuId}" },
|
|
536
|
+
method: "PATCH",
|
|
537
|
+
expectedParamName: "menuId",
|
|
538
|
+
},
|
|
539
|
+
{
|
|
540
|
+
name: "Cloud billing: PUT /subscriptions/{subscription_id}",
|
|
541
|
+
resource: "subscriptions",
|
|
542
|
+
group: { basePath: "/billing/subscriptions", methods: ["GET", "POST", "PUT"], paramPath: "/billing/subscriptions/{subscription_id}" },
|
|
543
|
+
method: "PUT",
|
|
544
|
+
expectedParamName: "subscription_id",
|
|
545
|
+
},
|
|
546
|
+
{
|
|
547
|
+
name: "Warehouse: PATCH /inventories/{uuid}",
|
|
548
|
+
resource: "inventories",
|
|
549
|
+
group: { basePath: "/api/inventories", methods: ["GET", "POST", "PATCH"], paramPath: "/api/inventories/{uuid}" },
|
|
550
|
+
method: "PATCH",
|
|
551
|
+
expectedParamName: "uuid",
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
name: "Minimal: PATCH /items (no paramPath)",
|
|
555
|
+
resource: "items",
|
|
556
|
+
group: { basePath: "/items", methods: ["GET", "POST", "PATCH"] },
|
|
557
|
+
method: "PATCH",
|
|
558
|
+
expectedParamName: "item_id",
|
|
559
|
+
},
|
|
560
|
+
];
|
|
561
|
+
it.each(testCases)("$name — produces valid mutation-recalc scenario", ({ resource, group, method, expectedParamName }) => {
|
|
562
|
+
const groups = makeGroups({ [resource]: group });
|
|
563
|
+
const scenarios = draftDiffDirectScenarios([{ method, path: group.paramPath ?? group.basePath }], groups);
|
|
564
|
+
const mutationRecalc = scenarios.find(s => s.scenarioName.endsWith("-add-items-recalculate"));
|
|
565
|
+
expect(mutationRecalc).toBeDefined();
|
|
566
|
+
expect(mutationRecalc.category).toBe("new_endpoint");
|
|
567
|
+
expect(mutationRecalc.testType).toBe("integration");
|
|
568
|
+
expect(mutationRecalc.priority).toBe("high");
|
|
569
|
+
// Step 1: POST to create resource (all test cases have POST)
|
|
570
|
+
expect(mutationRecalc.steps[0].method).toBe("POST");
|
|
571
|
+
expect(mutationRecalc.steps[0].path).toBe(group.basePath);
|
|
572
|
+
// Step 2: PUT/PATCH with mutation
|
|
573
|
+
const mutationStep = mutationRecalc.steps[1];
|
|
574
|
+
expect(mutationStep.method).toBe(method);
|
|
575
|
+
expect(mutationStep.bodyMustInclude).toBeDefined();
|
|
576
|
+
expect(mutationStep.bodyMustInclude.length).toBe(3);
|
|
577
|
+
// Verify NO hardcoded domain-specific field names
|
|
578
|
+
for (const hint of mutationStep.bodyMustInclude) {
|
|
579
|
+
expect(hint).not.toBe("items");
|
|
580
|
+
expect(hint).not.toBe("product_id");
|
|
581
|
+
expect(hint).not.toBe("quantity");
|
|
582
|
+
expect(hint).not.toBe("total_amount");
|
|
583
|
+
}
|
|
584
|
+
// Verify hints are descriptive (contain "e.g." or similar guidance)
|
|
585
|
+
expect(mutationStep.bodyMustInclude.some(h => h.includes("e.g."))).toBe(true);
|
|
586
|
+
// Chaining uses the actual path param name (only when paramPath exists)
|
|
587
|
+
if (group.paramPath) {
|
|
588
|
+
expect(mutationStep.chainsFrom).toBeDefined();
|
|
589
|
+
const chaining = mutationStep.chainsFrom;
|
|
590
|
+
expect(chaining.targetParam).toBe(expectedParamName);
|
|
591
|
+
expect(chaining.sourceStep).toBe(1);
|
|
592
|
+
expect(chaining.sourceField).toBe("id");
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
expect(mutationStep.chainsFrom).toBeUndefined();
|
|
596
|
+
}
|
|
597
|
+
// Step 3: GET verification (if paramPath exists)
|
|
598
|
+
if (group.paramPath) {
|
|
599
|
+
expect(mutationRecalc.steps).toHaveLength(3);
|
|
600
|
+
const getStep = mutationRecalc.steps[2];
|
|
601
|
+
expect(getStep.method).toBe("GET");
|
|
602
|
+
expect(getStep.expectedResponseFields).toBeDefined();
|
|
603
|
+
// Verify NO hardcoded domain-specific field names in response fields
|
|
604
|
+
for (const field of getStep.expectedResponseFields) {
|
|
605
|
+
expect(field).not.toBe("items");
|
|
606
|
+
expect(field).not.toMatch(/^items\.\*/);
|
|
607
|
+
expect(field).not.toBe("total_amount");
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
// chainingKeys uses the actual path param name, not hardcoded singular_id
|
|
611
|
+
expect(mutationRecalc.chainingKeys).toContain("id");
|
|
612
|
+
expect(mutationRecalc.chainingKeys).toContain(expectedParamName);
|
|
613
|
+
// Description is domain-agnostic
|
|
614
|
+
expect(mutationRecalc.description).toContain("derived totals");
|
|
615
|
+
expect(mutationRecalc.description).toContain("Mutation recalculation");
|
|
616
|
+
});
|
|
617
|
+
it("no paramPath: PATCH step targets basePath, no GET verification, fallback param name", () => {
|
|
618
|
+
const groups = makeGroups({
|
|
619
|
+
items: { basePath: "/items", methods: ["GET", "POST", "PATCH"] },
|
|
620
|
+
});
|
|
621
|
+
const scenarios = draftDiffDirectScenarios([{ method: "PATCH", path: "/items" }], groups);
|
|
622
|
+
const mutationRecalc = scenarios.find(s => s.scenarioName.endsWith("-add-items-recalculate"));
|
|
623
|
+
expect(mutationRecalc).toBeDefined();
|
|
624
|
+
// No paramPath → PATCH targets basePath, no GET step
|
|
625
|
+
expect(mutationRecalc.steps).toHaveLength(2);
|
|
626
|
+
expect(mutationRecalc.steps[1].path).toBe("/items");
|
|
627
|
+
// Fallback param name: singular + _id
|
|
628
|
+
expect(mutationRecalc.chainingKeys).toContain("item_id");
|
|
629
|
+
});
|
|
630
|
+
it("resource without POST: no create step, no chaining", () => {
|
|
631
|
+
const groups = makeGroups({
|
|
632
|
+
configs: { basePath: "/api/configs", methods: ["GET", "PATCH"], paramPath: "/api/configs/{config_id}" },
|
|
633
|
+
});
|
|
634
|
+
const scenarios = draftDiffDirectScenarios([{ method: "PATCH", path: "/api/configs/{config_id}" }], groups);
|
|
635
|
+
const mutationRecalc = scenarios.find(s => s.scenarioName.endsWith("-add-items-recalculate"));
|
|
636
|
+
expect(mutationRecalc).toBeDefined();
|
|
637
|
+
// No POST → starts with PATCH directly, no chaining
|
|
638
|
+
expect(mutationRecalc.steps[0].method).toBe("PATCH");
|
|
639
|
+
expect(mutationRecalc.steps[0].chainsFrom).toBeUndefined();
|
|
640
|
+
// Still has GET verification
|
|
641
|
+
expect(mutationRecalc.steps[1].method).toBe("GET");
|
|
642
|
+
expect(mutationRecalc.steps[1].chainsFrom).toBeUndefined();
|
|
643
|
+
});
|
|
381
644
|
});
|