Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/prompts/feature-flag-rollout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<env_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=<env_id> to execute each phase after user approval.${nativeModeCaveat}`,
},
}],
};
Expand Down
101 changes: 101 additions & 0 deletions tests/prompts/feature-flag-rollout.test.ts
Original file line number Diff line number Diff line change
@@ -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<Client> {
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<ReturnType<Client["getPrompt"]>>): 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");
});
});
40 changes: 40 additions & 0 deletions tests/registry/iacm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
162 changes: 161 additions & 1 deletion tests/registry/toolsets/release-management.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): Config {
Expand All @@ -24,6 +35,17 @@ function makeConfig(overrides: Partial<Config> = {}): Config {
} as Config;
}

function makeClient(requestFn?: (options: RequestOptions) => Promise<unknown>): HarnessClient {
return {
request: requestFn ?? vi.fn().mockResolvedValue({}),
account: "test-account",
} as unknown as HarnessClient;
}

function firstRequest(mockRequest: ReturnType<typeof vi.fn>): 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`);
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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<string, unknown> = { 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<string, unknown> = {
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<string, unknown> = { 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(
{
Expand Down Expand Up @@ -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}",
Expand Down Expand Up @@ -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");
});
});
Loading