Skip to content

Commit ea2cd5a

Browse files
committed
Percy Integration with new flow
1 parent d7eeeae commit ea2cd5a

File tree

16 files changed

+546
-216
lines changed

16 files changed

+546
-216
lines changed

src/tools/percy-change.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import logger from "../logger.js";
2+
import { BrowserStackConfig } from "../lib/types.js";
3+
import { getBrowserStackAuth } from "../lib/get-auth.js";
4+
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
5+
import { getPercyBuildCount } from "./review-agent-utils/build-counts.js";
6+
import { getPercySnapshotIds } from "./review-agent-utils/percy-snapshots.js";
7+
import { PercyIntegrationTypeEnum } from "./sdk-utils/common/types.js";
8+
import { fetchPercyToken } from "./sdk-utils/percy-web/fetchPercyToken.js";
9+
10+
import {
11+
getPercySnapshotDiffs,
12+
PercySnapshotDiff,
13+
} from "./review-agent-utils/percy-diffs.js";
14+
15+
export async function fetchPercyChanges(
16+
args: { project_name: string },
17+
config: BrowserStackConfig,
18+
): Promise<CallToolResult> {
19+
const { project_name } = args;
20+
const authorization = getBrowserStackAuth(config);
21+
22+
// Get Percy token for the project
23+
const percyToken = await fetchPercyToken(project_name, authorization, {
24+
type: PercyIntegrationTypeEnum.WEB,
25+
});
26+
27+
// Get build info (noBuilds, isFirstBuild, lastBuildId)
28+
const { noBuilds, isFirstBuild, lastBuildId } =
29+
await getPercyBuildCount(percyToken);
30+
31+
if (noBuilds) {
32+
return {
33+
content: [
34+
{
35+
type: "text",
36+
text: "No Percy builds found. Please run your first Percy scan to start visual testing.",
37+
},
38+
],
39+
};
40+
}
41+
42+
if (isFirstBuild || !lastBuildId) {
43+
return {
44+
content: [
45+
{
46+
type: "text",
47+
text: "This is the first Percy build. No baseline exists to compare changes.",
48+
},
49+
],
50+
};
51+
}
52+
53+
// Get snapshot IDs for the latest build
54+
const snapshotIds = await getPercySnapshotIds(lastBuildId, percyToken);
55+
logger.info(
56+
`Fetched ${snapshotIds.length} snapshot IDs for build: ${lastBuildId} as ${snapshotIds.join(", ")}`,
57+
);
58+
59+
// Fetch all diffs concurrently and flatten results
60+
const allDiffs = await getPercySnapshotDiffs(snapshotIds, percyToken);
61+
62+
if (allDiffs.length === 0) {
63+
return {
64+
content: [
65+
{
66+
type: "text",
67+
text: "AI Summary is not yet available for this build/framework. There may still be visual changes—please review the build on the dashboard. Support for AI Summary will be added soon.",
68+
},
69+
],
70+
};
71+
}
72+
73+
return {
74+
content: allDiffs.map((diff: PercySnapshotDiff) => ({
75+
type: "text",
76+
text: `${diff.name}${diff.title}: ${diff.description ?? ""}`,
77+
})),
78+
};
79+
}

src/tools/percy-sdk.ts

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,36 @@
1-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1+
import { trackMCP } from "../index.js";
22
import { BrowserStackConfig } from "../lib/types.js";
3+
import { fetchPercyChanges } from "./percy-change.js";
4+
import { addListTestFiles } from "./list-test-files.js";
5+
import { runPercyScan } from "./run-percy-scan.js";
6+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
37
import { SetUpPercyParamsShape } from "./sdk-utils/common/schema.js";
48
import { updateTestsWithPercyCommands } from "./add-percy-snapshots.js";
5-
import { addListTestFiles } from "./list-test-files.js";
6-
import { trackMCP } from "../index.js";
9+
import { approveOrDeclinePercyBuild } from "./review-agent-utils/percy-approve-reject.js";
10+
711
import {
812
setUpPercyHandler,
913
setUpSimulatePercyChangeHandler,
1014
} from "./sdk-utils/handler.js";
15+
1116
import {
1217
SETUP_PERCY_DESCRIPTION,
1318
SIMULATE_PERCY_CHANGE_DESCRIPTION,
1419
LIST_TEST_FILES_DESCRIPTION,
1520
PERCY_SNAPSHOT_COMMANDS_DESCRIPTION,
1621
} from "./sdk-utils/common/constants.js";
22+
1723
import {
1824
ListTestFilesParamsShape,
1925
UpdateTestFileWithInstructionsParams,
2026
} from "./percy-snapshot-utils/constants.js";
2127

28+
import {
29+
RunPercyScanParamsShape,
30+
FetchPercyChangesParamsShape,
31+
ManagePercyBuildApprovalParamsShape,
32+
} from "./sdk-utils/common/schema.js";
33+
2234
export function registerPercyTools(
2335
server: McpServer,
2436
config: BrowserStackConfig,
@@ -153,6 +165,33 @@ export function registerPercyTools(
153165
},
154166
);
155167

168+
tools.runPercyScan = server.tool(
169+
"runPercyScan",
170+
"Run a Percy visual test scan. Example prompts : Run this Percy build/scan.Never run percy scan/build without this tool",
171+
RunPercyScanParamsShape,
172+
async (args) => {
173+
return runPercyScan(args, config);
174+
},
175+
);
176+
177+
tools.fetchPercyChanges = server.tool(
178+
"fetchPercyChanges",
179+
"Retrieves and summarizes all visual changes detected by Percy between the latest and previous builds, helping quickly review what has changed in your project.",
180+
FetchPercyChangesParamsShape,
181+
async (args) => {
182+
return await fetchPercyChanges(args, config);
183+
},
184+
);
185+
186+
tools.managePercyBuildApproval = server.tool(
187+
"managePercyBuildApproval",
188+
"Approve or reject a Percy build",
189+
ManagePercyBuildApprovalParamsShape,
190+
async (args) => {
191+
return await approveOrDeclinePercyBuild(args, config);
192+
},
193+
);
194+
156195
return tools;
157196
}
158197

src/tools/percy-snapshot-utils/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export async function updateFileAndStep(
3434
if (nextIndex === total) {
3535
content.push({
3636
type: "text",
37-
text: `Step 4: Percy snapshot commands have been added to all files. You can now run the Percy build using the above command.`,
37+
text: `Step 3: Percy snapshot commands have been added to all files. You can now run the tool runPercyScan to run the percy scan.`,
3838
});
3939
}
4040

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Utility for fetching the count of Percy builds.
2+
export async function getPercyBuildCount(percyToken: string) {
3+
const apiUrl = `https://percy.io/api/v1/builds`;
4+
5+
const response = await fetch(apiUrl, {
6+
headers: {
7+
Authorization: `Token token=${percyToken}`,
8+
"Content-Type": "application/json",
9+
},
10+
});
11+
12+
if (!response.ok) {
13+
throw new Error(`Failed to fetch Percy builds: ${response.statusText}`);
14+
}
15+
16+
const data = await response.json();
17+
const builds = data.data ?? [];
18+
19+
let isFirstBuild = false;
20+
let lastBuildId;
21+
22+
if (builds.length === 0) {
23+
return { noBuilds: true, isFirstBuild: false, lastBuildId: undefined };
24+
} else if (builds.length === 1) {
25+
isFirstBuild = true;
26+
lastBuildId = builds[0].id;
27+
} else {
28+
isFirstBuild = false;
29+
lastBuildId = builds[0].id;
30+
}
31+
32+
return { noBuilds: false, isFirstBuild, lastBuildId };
33+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { BrowserStackConfig } from "../../lib/types.js";
2+
import { getBrowserStackAuth } from "../../lib/get-auth.js";
3+
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
4+
5+
export async function approveOrDeclinePercyBuild(
6+
args: { buildId: string; action: "approve" | "unapprove" | "reject" },
7+
config: BrowserStackConfig,
8+
): Promise<CallToolResult> {
9+
const { buildId, action } = args;
10+
11+
// Get Basic Auth credentials
12+
const [username, accessKey] = getBrowserStackAuth(config).split(":");
13+
const authHeader = `Basic ${Buffer.from(`${username}:${accessKey}`).toString("base64")}`;
14+
15+
// Prepare request body
16+
const body = {
17+
data: {
18+
type: "reviews",
19+
attributes: { action },
20+
relationships: {
21+
build: { data: { type: "builds", id: buildId } },
22+
},
23+
},
24+
};
25+
26+
// Send request to Percy API
27+
const response = await fetch("https://percy.io/api/v1/reviews", {
28+
method: "POST",
29+
headers: {
30+
"Content-Type": "application/json",
31+
Authorization: authHeader,
32+
},
33+
body: JSON.stringify(body),
34+
});
35+
36+
if (!response.ok) {
37+
const errorText = await response.text();
38+
throw new Error(
39+
`Percy build ${action} failed: ${response.status} ${errorText}`,
40+
);
41+
}
42+
43+
const result = await response.json();
44+
45+
return {
46+
content: [
47+
{
48+
type: "text",
49+
text: `Percy build ${buildId} was ${result.data.attributes["review-state"]} by ${result.data.attributes["action-performed-by"].user_name}`,
50+
},
51+
],
52+
};
53+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* Utility for fetching Percy build information.
3+
*/
4+
5+
export interface PercyBuildInfo {
6+
noBuilds: boolean;
7+
isFirstBuild: boolean;
8+
lastBuildId?: string;
9+
}
10+
11+
/**
12+
* Fetches Percy build information for a given token.
13+
* @param percyToken - The Percy API token.
14+
* @returns PercyBuildInfo object.
15+
*/
16+
export async function getPercyBuildInfo(
17+
percyToken: string,
18+
): Promise<PercyBuildInfo> {
19+
const apiUrl = "https://percy.io/api/v1/builds";
20+
21+
const response = await fetch(apiUrl, {
22+
headers: {
23+
Authorization: `Token token=${percyToken}`,
24+
"Content-Type": "application/json",
25+
},
26+
});
27+
28+
if (!response.ok) {
29+
throw new Error(`Failed to fetch Percy builds: ${response.statusText}`);
30+
}
31+
32+
const data = await response.json();
33+
const builds = data.data ?? [];
34+
35+
if (builds.length === 0) {
36+
return { noBuilds: true, isFirstBuild: false, lastBuildId: undefined };
37+
}
38+
39+
const isFirstBuild = builds.length === 1;
40+
const lastBuildId = builds[0].id;
41+
42+
return { noBuilds: false, isFirstBuild, lastBuildId };
43+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* Utility for fetching and aggregating Percy snapshot diffs.
3+
*/
4+
5+
export interface PercySnapshotDiff {
6+
id: string;
7+
name: string | null;
8+
title: string;
9+
description: string | null;
10+
coordinates: any;
11+
}
12+
13+
/**
14+
* Fetches diffs for a single Percy snapshot.
15+
* @param snapshotId - The Percy snapshot ID.
16+
* @param percyToken - The Percy API token.
17+
* @returns Array of PercySnapshotDiff objects.
18+
*/
19+
export async function getPercySnapshotDiff(
20+
snapshotId: string,
21+
percyToken: string,
22+
): Promise<PercySnapshotDiff[]> {
23+
const apiUrl = `https://percy.io/api/v1/snapshots/${snapshotId}`;
24+
25+
const response = await fetch(apiUrl, {
26+
headers: {
27+
Authorization: `Token token=${percyToken}`,
28+
"Content-Type": "application/json",
29+
},
30+
});
31+
32+
if (!response.ok) {
33+
throw new Error(
34+
`Failed to fetch Percy snapshot ${snapshotId}: ${response.statusText}`,
35+
);
36+
}
37+
38+
const data = await response.json();
39+
const pageUrl = data.data.attributes?.name || null;
40+
41+
const changes: PercySnapshotDiff[] = [];
42+
const comparisons =
43+
data.included?.filter((item: any) => item.type === "comparisons") ?? [];
44+
45+
for (const comparison of comparisons) {
46+
const appliedRegions = comparison.attributes?.["applied-regions"] ?? [];
47+
for (const region of appliedRegions) {
48+
if (region.ignored) continue;
49+
changes.push({
50+
id: String(region.id),
51+
name: pageUrl,
52+
title: region.change_title,
53+
description: region.change_description ?? null,
54+
coordinates: region.coordinates ?? null,
55+
});
56+
}
57+
}
58+
59+
return changes;
60+
}
61+
62+
/**
63+
* Fetches and flattens all diffs for an array of Percy snapshot IDs.
64+
* @param snapshotIds - Array of Percy snapshot IDs.
65+
* @param percyToken - The Percy API token.
66+
* @returns Flat array of PercySnapshotDiff objects.
67+
*/
68+
export async function getPercySnapshotDiffs(
69+
snapshotIds: string[],
70+
percyToken: string,
71+
): Promise<PercySnapshotDiff[]> {
72+
const allDiffs = await Promise.all(
73+
snapshotIds.map((id) => getPercySnapshotDiff(id, percyToken)),
74+
);
75+
return allDiffs.flat();
76+
}

0 commit comments

Comments
 (0)