diff --git a/src/prompts/feature-flag-rollout.ts b/src/prompts/feature-flag-rollout.ts index 09f33337e..60b72f9f6 100644 --- a/src/prompts/feature-flag-rollout.ts +++ b/src/prompts/feature-flag-rollout.ts @@ -46,7 +46,7 @@ Steps: 6. **Safety gates**: Identify metrics or health checks between each phase 7. **Rollback plan**: Use kill action to immediately turn off the flag if issues arise -Present the rollout plan for review. Use harness_execute with resource_type="fme_feature_flag", action="kill" or action="restore", ${scopeArgs}, feature_flag_name="${featureFlagName}", environment_id= to execute each phase after user approval.`, +Present the rollout plan for review. Use harness_execute with resource_type="fme_feature_flag", action="kill" or action="restore", ${scopeArgs}, feature_flag_name="${featureFlagName}", environment_id= to execute each phase after user approval.${nativeModeCaveat}`, }, }], }; diff --git a/tests/prompts/feature-flag-rollout.test.ts b/tests/prompts/feature-flag-rollout.test.ts new file mode 100644 index 000000000..a35b38a25 --- /dev/null +++ b/tests/prompts/feature-flag-rollout.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from "vitest"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { registerFeatureFlagRolloutPrompt } from "../../src/prompts/feature-flag-rollout.js"; + +async function createTestClient(): Promise { + const server = new McpServer( + { name: "test-server", version: "0.0.1" }, + { capabilities: { prompts: {} } }, + ); + registerFeatureFlagRolloutPrompt(server); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test-client", version: "0.0.1" }); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + return client; +} + +function promptText(result: Awaited>): string { + return (result.messages[0].content as { type: string; text: string }).text; +} + +describe("feature-flag-rollout prompt", () => { + it("appears in the prompt list", async () => { + const client = await createTestClient(); + const { prompts } = await client.listPrompts(); + + const prompt = prompts.find((p) => p.name === "feature-flag-rollout"); + expect(prompt).toBeDefined(); + expect(prompt!.description).toContain("progressive FME feature flag rollout"); + }); + + it("throws when neither workspaceId nor orgId+projectId is provided", async () => { + const client = await createTestClient(); + + await expect( + client.getPrompt({ + name: "feature-flag-rollout", + arguments: { featureFlagName: "my_flag" }, + }), + ).rejects.toThrow("Provide either workspaceId (deprecated) or orgId + projectId."); + }); + + it("interpolates Harness-native scope args and warns about fme_rollout_status", async () => { + const client = await createTestClient(); + const result = await client.getPrompt({ + name: "feature-flag-rollout", + arguments: { + featureFlagName: "checkout_v2", + orgId: "myOrg", + projectId: "myProj", + }, + }); + + const text = promptText(result); + expect(text).toContain('feature_flag_name="checkout_v2"'); + expect(text).toContain('org_id="myOrg", project_id="myProj"'); + expect(text).toContain('resource_type="fme_rollout_status"'); + expect(text).toContain("fme_rollout_status is not yet implemented server-side"); + expect(text).not.toContain('workspace_id="'); + }); + + it("interpolates legacy workspace scope without native-mode caveat", async () => { + const client = await createTestClient(); + const result = await client.getPrompt({ + name: "feature-flag-rollout", + arguments: { + featureFlagName: "checkout_v2", + workspaceId: "ws-legacy-1", + }, + }); + + const text = promptText(result); + expect(text).toContain('workspace_id="ws-legacy-1"'); + expect(text).not.toContain("fme_rollout_status is not yet implemented"); + expect(text).not.toContain('org_id="'); + }); + + it("references harness_execute kill and restore actions", async () => { + const client = await createTestClient(); + const result = await client.getPrompt({ + name: "feature-flag-rollout", + arguments: { + featureFlagName: "dark_mode", + orgId: "o1", + projectId: "p1", + }, + }); + + const text = promptText(result); + expect(text).toContain('action="kill"'); + expect(text).toContain('action="restore"'); + expect(text).toContain("harness_execute"); + }); +}); diff --git a/tests/registry/iacm.test.ts b/tests/registry/iacm.test.ts index 5b0eaca9c..1cb8ca352 100644 --- a/tests/registry/iacm.test.ts +++ b/tests/registry/iacm.test.ts @@ -1314,4 +1314,44 @@ describe("iacm registry dispatch", () => { body: { protocol: ["5.0"], gpg_key_id: "key-1" }, }); }); + + it("rejects create when body.type is missing before API call", async () => { + const mockRequest = vi.fn(); + const registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "iacm" })); + + await expect( + registry.dispatch(makeClient(mockRequest), "iacm_provider", "create", { + body: { description: "AWS provider without type" }, + }), + ).rejects.toThrow('Missing required field "type"'); + + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it("rejects version update when provider id is missing", async () => { + const mockRequest = vi.fn(); + const registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "iacm" })); + + await expect( + registry.dispatch(makeClient(mockRequest), "iacm_provider", "update", { + body: { version: "1.0.0", protocol: ["5.0"], gpg_key_id: "key-1" }, + }), + ).rejects.toThrow(/Missing required param\(s\) for iacm_provider\.update: id/); + + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it("rejects version create when body.version is missing", async () => { + const mockRequest = vi.fn(); + const registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "iacm" })); + + await expect( + registry.dispatch(makeClient(mockRequest), "iacm_provider", "update", { + id: "provider-1", + body: { protocol: ["5.0"], gpg_key_id: "key-1" }, + }), + ).rejects.toThrow('Missing required field "version"'); + + expect(mockRequest).not.toHaveBeenCalled(); + }); }); diff --git a/tests/registry/toolsets/release-management.test.ts b/tests/registry/toolsets/release-management.test.ts index 085f43c0e..1391acdd7 100644 --- a/tests/registry/toolsets/release-management.test.ts +++ b/tests/registry/toolsets/release-management.test.ts @@ -1,12 +1,23 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { releaseManagementToolset } from "../../../src/registry/toolsets/release-management.js"; import { Registry } from "../../../src/registry/index.js"; import type { Config } from "../../../src/config.js"; +import type { HarnessClient } from "../../../src/client/harness-client.js"; +import type { RequestOptions } from "../../../src/client/types.js"; import { releaseGetExtract, rmgYamlEntityExtract, rmgYamlEntityDeleteExtract, yamlWriteBody, + releaseListBody, + releaseListExtract, + releaseExecutionPhaseOutputPath, + releaseExecutionPhaseInputPath, + releaseExecutionActivityOutputPath, + normalizeReleaseActivityExecutionInput, + normalizeReleaseTaskLimit, + releaseActivityExecutionListExtract, + RMG_MAX_TASK_LIMIT, } from "../../../src/registry/extractors.js"; function makeConfig(overrides: Partial = {}): Config { @@ -24,6 +35,17 @@ function makeConfig(overrides: Partial = {}): Config { } as Config; } +function makeClient(requestFn?: (options: RequestOptions) => Promise): HarnessClient { + return { + request: requestFn ?? vi.fn().mockResolvedValue({}), + account: "test-account", + } as unknown as HarnessClient; +} + +function firstRequest(mockRequest: ReturnType): RequestOptions { + return mockRequest.mock.calls[0][0] as RequestOptions; +} + function resource(type: string) { const r = releaseManagementToolset.resources.find((x) => x.resourceType === type); if (!r) throw new Error(`${type} missing from release-management toolset`); @@ -208,6 +230,23 @@ describe("release-management execution resources", () => { expect(out._hint).toContain("2 unfiltered items"); }); + it("release list extractor matches releaseStatus field alias", () => { + const out = releaseListExtract( + { content: [{ id: "r1", releaseStatus: "Running" }, { id: "r2", releaseStatus: "Failed" }] }, + { status: "running" }, + ) as { items: Array<{ id: string }> }; + expect(out.items.map((r) => r.id)).toEqual(["r1"]); + }); + + it("release list extractor handles raw array payloads", () => { + const out = releaseListExtract([{ id: "r1" }, { id: "r2" }]) as { items: unknown[] }; + expect(out.items).toHaveLength(2); + }); + + it("release list body returns empty scopes when org/project absent", () => { + expect(releaseListBody({})).toEqual({ scopes: [] }); + }); + it("release_execution_phase list extractor maps phases to items", () => { const out = extractPhases({ release_id: "slug-1", @@ -243,6 +282,46 @@ describe("release-management execution resources", () => { expect(input.status).toEqual(["RUNNING", "FAILED"]); }); + it("release_execution_activity preflight defaults sort to start_ts desc", () => { + const input: Record = { release_id: "rel-1" }; + normalizeReleaseActivityExecutionInput(input); + expect(input.sort).toEqual(["start_ts", "desc"]); + }); + + it("release_execution_activity preflight splits activity_type CSV", () => { + const input: Record = { + release_id: "rel-1", + activity_type: "Pipeline, Manual", + }; + normalizeReleaseActivityExecutionInput(input); + expect(input.activity_type).toEqual(["Pipeline", "Manual"]); + }); + + it("release_execution_task preflight clamps limit to RMG_MAX_TASK_LIMIT", () => { + const input: Record = { release_id: "rel-1", limit: 9999 }; + normalizeReleaseTaskLimit(input); + expect(input.limit).toBe(RMG_MAX_TASK_LIMIT); + }); + + it("release_execution_activity list extractor forwards Spring pagination metadata", () => { + const out = releaseActivityExecutionListExtract({ + content: [{ identifier: "act-1" }], + totalElements: 42, + totalPages: 5, + size: 10, + number: 2, + numberOfElements: 1, + first: false, + last: true, + }) as { + items: unknown[]; + pagination: { total_elements?: number; last?: boolean }; + }; + expect(out.items).toHaveLength(1); + expect(out.pagination.total_elements).toBe(42); + expect(out.pagination.last).toBe(true); + }); + it("phase input pathBuilder hits /input endpoint", () => { const path = buildPhaseInputPath( { @@ -297,6 +376,22 @@ describe("release-management execution resources", () => { ); }); + it("phase output pathBuilder rejects missing release_id", () => { + expect(() => releaseExecutionPhaseOutputPath({ phase_identifier: "deploy" })) + .toThrow("release_id is required"); + }); + + it("phase input pathBuilder rejects missing phase_identifier", () => { + expect(() => releaseExecutionPhaseInputPath({ release_id: "rel-1" })) + .toThrow("phase_identifier is required"); + }); + + it("activity output pathBuilder rejects missing activity_identifier", () => { + expect(() => + releaseExecutionActivityOutputPath({ release_id: "rel-1", phase_identifier: "deploy" }), + ).toThrow("activity_identifier is required"); + }); + it("release_input get hits releaseInput endpoint", () => { expect(releaseInputResource.operations.get?.path).toBe( "/api/orchestration/execution/releaseInput/{releaseId}", @@ -396,3 +491,68 @@ describe("release-management extractors", () => { expect(() => yamlWriteBody({ body: null })).toThrow("body is required"); }); }); + +describe("release-management registry dispatch", () => { + it("release list uses RMG gateway, header scoping, and scopes in POST body", async () => { + const mockRequest = vi.fn().mockResolvedValue({ content: [] }); + const registry = new Registry(makeConfig()); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "release", "list", { + org_id: "org1", + project_id: "proj1", + start_ts: 1000, + end_ts: 2000, + }); + + const request = firstRequest(mockRequest); + expect(request.method).toBe("POST"); + expect(request.path).toBe("/api/release/list"); + expect(request.baseUrl).toBe("https://app.harness.io/gateway/rmg"); + expect(request.headerBasedScoping).toBe(true); + expect(request.params?.accountIdentifier).toBeUndefined(); + expect(request.body).toEqual({ + scopes: [{ orgIdentifier: "org1", projectIdentifier: "proj1" }], + }); + expect(request.params).toMatchObject({ + type: "Orchestration", + expectedStartTs: 1000, + expectedEndTs: 2000, + }); + }); + + it("release_process create does not inject orgIdentifier into YAML body", async () => { + const mockRequest = vi.fn().mockResolvedValue({ identifier: "proc-1" }); + const registry = new Registry(makeConfig()); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "release_process", "create", { + org_id: "org1", + project_id: "proj1", + body: { yaml: "process:\n identifier: proc-1" }, + }); + + const request = firstRequest(mockRequest); + expect(request.baseUrl).toBe("https://app.harness.io/gateway/rmg"); + expect(request.headerBasedScoping).toBe(true); + expect(request.body).toEqual({ yaml: "process:\n identifier: proc-1" }); + expect(request.body).not.toHaveProperty("orgIdentifier"); + }); + + it("release_execution_phase list uses header scoping without accountIdentifier", async () => { + const mockRequest = vi.fn().mockResolvedValue({ phases: [] }); + const registry = new Registry(makeConfig()); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "release_execution_phase", "list", { + release_id: "rel-slug-1.0.0-abc", + }); + + const request = firstRequest(mockRequest); + expect(request.method).toBe("GET"); + expect(request.baseUrl).toBe("https://app.harness.io/gateway/rmg"); + expect(request.headerBasedScoping).toBe(true); + expect(request.params?.accountIdentifier).toBeUndefined(); + expect(request.path).toContain("rel-slug-1.0.0-abc"); + }); +});