Skip to content

Commit fce92b2

Browse files
Merge pull request #160 from tech-sushant/percy-file-support
feat: Percy test file handling by supporting specific file paths
2 parents 0ea6358 + 66803a0 commit fce92b2

File tree

12 files changed

+264
-80
lines changed

12 files changed

+264
-80
lines changed

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,4 @@ process.on("exit", () => {
5151
export { setLogger } from "./logger.js";
5252
export { BrowserStackMcpServer } from "./server-factory.js";
5353
export { trackMCP } from "./lib/instrumentation.js";
54+
export const PackageJsonVersion = packageJson.version;

src/lib/inmemory-store.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,13 @@
11
export const signedUrlMap = new Map<string, object>();
2-
export const testFilePathsMap = new Map<string, string[]>();
2+
3+
let _storedPercyResults: any = null;
4+
5+
export const storedPercyResults = {
6+
get: () => _storedPercyResults,
7+
set: (value: any) => {
8+
_storedPercyResults = value;
9+
},
10+
clear: () => {
11+
_storedPercyResults = null;
12+
},
13+
};

src/tools/add-percy-snapshots.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,39 @@
1-
import { testFilePathsMap } from "../lib/inmemory-store.js";
1+
import { storedPercyResults } from "../lib/inmemory-store.js";
22
import { updateFileAndStep } from "./percy-snapshot-utils/utils.js";
33
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
44
import { percyWebSetupInstructions } from "../tools/sdk-utils/percy-web/handler.js";
55

66
export async function updateTestsWithPercyCommands(args: {
7-
uuid: string;
87
index: number;
98
}): Promise<CallToolResult> {
10-
const { uuid, index } = args;
11-
const filePaths = testFilePathsMap.get(uuid);
12-
13-
if (!filePaths) {
14-
throw new Error(`No test files found in memory for UUID: ${uuid}`);
9+
const { index } = args;
10+
const stored = storedPercyResults.get();
11+
if (!stored || !stored.testFiles) {
12+
throw new Error(
13+
`No test files found in memory. Please call listTestFiles first.`,
14+
);
1515
}
1616

17+
const fileStatusMap = stored.testFiles;
18+
const filePaths = Object.keys(fileStatusMap);
19+
1720
if (index < 0 || index >= filePaths.length) {
1821
throw new Error(
19-
`Invalid index: ${index}. There are ${filePaths.length} files for UUID: ${uuid}`,
22+
`Invalid index: ${index}. There are ${filePaths.length} files available.`,
2023
);
2124
}
25+
2226
const result = await updateFileAndStep(
2327
filePaths[index],
2428
index,
2529
filePaths.length,
2630
percyWebSetupInstructions,
2731
);
2832

33+
const updatedStored = { ...stored };
34+
updatedStored.testFiles[filePaths[index]] = true; // true = updated
35+
storedPercyResults.set(updatedStored);
36+
2937
return {
3038
content: result,
3139
};

src/tools/list-test-files.ts

Lines changed: 56 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,80 @@
11
import { listTestFiles } from "./percy-snapshot-utils/detect-test-files.js";
2-
import { testFilePathsMap } from "../lib/inmemory-store.js";
3-
import crypto from "crypto";
2+
import { storedPercyResults } from "../lib/inmemory-store.js";
3+
import { updateFileAndStep } from "./percy-snapshot-utils/utils.js";
4+
import { percyWebSetupInstructions } from "./sdk-utils/percy-web/handler.js";
45
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
56

6-
export async function addListTestFiles(args: any): Promise<CallToolResult> {
7-
const { dirs, language, framework } = args;
8-
let testFiles: string[] = [];
9-
10-
if (!dirs || dirs.length === 0) {
7+
export async function addListTestFiles(): Promise<CallToolResult> {
8+
const storedResults = storedPercyResults.get();
9+
if (!storedResults) {
1110
throw new Error(
12-
"No directories provided to add the test files. Please provide test directories to add percy snapshot commands.",
11+
"No Framework details found. Please call expandPercyVisualTesting first to fetch the framework details.",
1312
);
1413
}
1514

16-
for (const dir of dirs) {
17-
const files = await listTestFiles({
18-
language,
19-
framework,
20-
baseDir: dir,
21-
});
15+
const language = storedResults.detectedLanguage;
16+
const framework = storedResults.detectedTestingFramework;
17+
18+
// Use stored paths from setUpPercy
19+
const dirs = storedResults.folderPaths;
20+
const files = storedResults.filePaths;
21+
22+
let testFiles: string[] = [];
23+
24+
if (files && files.length > 0) {
2225
testFiles = testFiles.concat(files);
2326
}
2427

28+
if (dirs && dirs.length > 0) {
29+
for (const dir of dirs) {
30+
const discoveredFiles = await listTestFiles({
31+
language,
32+
framework,
33+
baseDir: dir,
34+
});
35+
testFiles = testFiles.concat(discoveredFiles);
36+
}
37+
}
38+
39+
// Validate that we have at least one test file
2540
if (testFiles.length === 0) {
26-
throw new Error("No test files found");
41+
throw new Error(
42+
"No test files found. Please provide either specific file paths (files) or directory paths (dirs) containing test files.",
43+
);
2744
}
2845

29-
// Generate a UUID and store the test files in memory
30-
const uuid = crypto.randomUUID();
31-
testFilePathsMap.set(uuid, testFiles);
46+
if (testFiles.length === 1) {
47+
const result = await updateFileAndStep(
48+
testFiles[0],
49+
0,
50+
1,
51+
percyWebSetupInstructions,
52+
);
53+
return {
54+
content: result,
55+
};
56+
}
57+
58+
// For multiple files, store directly in testFiles
59+
const fileStatusMap: { [key: string]: boolean } = {};
60+
testFiles.forEach((file) => {
61+
fileStatusMap[file] = false; // false = not updated, true = updated
62+
});
63+
64+
// Update storedPercyResults with test files
65+
const updatedStored = { ...storedResults };
66+
updatedStored.testFiles = fileStatusMap;
67+
storedPercyResults.set(updatedStored);
3268

3369
return {
3470
content: [
3571
{
3672
type: "text",
37-
text: `The Test files are stored in memory with id ${uuid} and the total number of tests files found is ${testFiles.length}. You can use this UUID to retrieve the tests file paths later.`,
73+
text: `The Test files are stored in memory and the total number of tests files found is ${testFiles.length}.`,
3874
},
3975
{
4076
type: "text",
41-
text: `You can now use the tool addPercySnapshotCommands to update the test file with Percy commands for visual testing with the UUID ${uuid}`,
77+
text: `You can now use the tool addPercySnapshotCommands to update the test file with Percy commands for visual testing.`,
4278
},
4379
],
4480
};

src/tools/percy-sdk.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,7 @@ import {
1818
PERCY_SNAPSHOT_COMMANDS_DESCRIPTION,
1919
SIMULATE_PERCY_CHANGE_DESCRIPTION,
2020
} from "./sdk-utils/common/constants.js";
21-
22-
import {
23-
ListTestFilesParamsShape,
24-
UpdateTestFileWithInstructionsParams,
25-
} from "./percy-snapshot-utils/constants.js";
21+
import { UpdateTestFileWithInstructionsParams } from "./percy-snapshot-utils/constants.js";
2622

2723
import {
2824
RunPercyScanParamsShape,
@@ -126,11 +122,11 @@ export function registerPercyTools(
126122
tools.listTestFiles = server.tool(
127123
"listTestFiles",
128124
LIST_TEST_FILES_DESCRIPTION,
129-
ListTestFilesParamsShape,
130-
async (args) => {
125+
{},
126+
async () => {
131127
try {
132128
trackMCP("listTestFiles", server.server.getClientVersion()!, config);
133-
return addListTestFiles(args);
129+
return addListTestFiles();
134130
} catch (error) {
135131
return handleMCPError("listTestFiles", server, config, error);
136132
}

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

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,11 @@
11
import { z } from "zod";
2-
import {
3-
SDKSupportedLanguages,
4-
SDKSupportedTestingFrameworks,
5-
} from "../sdk-utils/common/types.js";
62
import { SDKSupportedLanguage } from "../sdk-utils/common/types.js";
73
import { DetectionConfig } from "./types.js";
84

95
export const UpdateTestFileWithInstructionsParams = {
10-
uuid: z
11-
.string()
12-
.describe("UUID referencing the in-memory array of test file paths"),
136
index: z.number().describe("Index of the test file to update"),
147
};
158

16-
export const ListTestFilesParamsShape = {
17-
dirs: z
18-
.array(z.string())
19-
.describe("Array of directory paths to search for test files"),
20-
language: z
21-
.enum(SDKSupportedLanguages as [string, ...string[]])
22-
.describe("Programming language"),
23-
framework: z
24-
.enum(SDKSupportedTestingFrameworks as [string, ...string[]])
25-
.describe("Testing framework (optional)"),
26-
};
27-
289
export const TEST_FILE_DETECTION: Record<
2910
SDKSupportedLanguage,
3011
DetectionConfig

src/tools/run-percy-scan.ts

Lines changed: 75 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ import { PercyIntegrationTypeEnum } from "./sdk-utils/common/types.js";
33
import { BrowserStackConfig } from "../lib/types.js";
44
import { getBrowserStackAuth } from "../lib/get-auth.js";
55
import { fetchPercyToken } from "./sdk-utils/percy-web/fetchPercyToken.js";
6+
import { storedPercyResults } from "../lib/inmemory-store.js";
7+
import {
8+
getFrameworkTestCommand,
9+
PERCY_FALLBACK_STEPS,
10+
} from "./sdk-utils/percy-web/constants.js";
11+
import path from "path";
612

713
export async function runPercyScan(
814
args: {
@@ -18,25 +24,22 @@ export async function runPercyScan(
1824
type: integrationType,
1925
});
2026

21-
const steps: string[] = [generatePercyTokenInstructions(percyToken)];
22-
23-
if (instruction) {
24-
steps.push(
25-
`Use the provided test command with Percy:\n${instruction}`,
26-
`If this command fails or is incorrect, fall back to the default approach below.`,
27-
);
28-
}
29-
30-
steps.push(
31-
`Attempt to infer the project's test command from context (high confidence commands first):
32-
- Java → mvn test
33-
- Python → pytest
34-
- Node.js → npm test or yarn test
35-
- Cypress → cypress run
36-
or from package.json scripts`,
37-
`Wrap the inferred command with Percy along with label: \nnpx percy exec --labels=mcp -- <test command>`,
38-
`If the test command cannot be inferred confidently, ask the user directly for the correct test command.`,
39-
);
27+
// Check if we have stored data and project matches
28+
const stored = storedPercyResults.get();
29+
30+
// Compute if we have updated files to run
31+
const hasUpdatedFiles = checkForUpdatedFiles(stored, projectName);
32+
const updatedFiles = hasUpdatedFiles ? getUpdatedFiles(stored) : [];
33+
34+
// Build steps array with conditional spread
35+
const steps = [
36+
generatePercyTokenInstructions(percyToken),
37+
...(hasUpdatedFiles ? generateUpdatedFilesSteps(stored, updatedFiles) : []),
38+
...(instruction && !hasUpdatedFiles
39+
? generateInstructionSteps(instruction)
40+
: []),
41+
...(!hasUpdatedFiles ? PERCY_FALLBACK_STEPS : []),
42+
];
4043

4144
const instructionContext = steps
4245
.map((step, index) => `${index + 1}. ${step}`)
@@ -59,3 +62,56 @@ export PERCY_TOKEN="${percyToken}"
5962
6063
(For Windows: use 'setx PERCY_TOKEN "${percyToken}"' or 'set PERCY_TOKEN=${percyToken}' as appropriate.)`;
6164
}
65+
66+
const toAbs = (p: string): string | undefined =>
67+
p ? path.resolve(p) : undefined;
68+
69+
function checkForUpdatedFiles(
70+
stored: any, // storedPercyResults structure
71+
projectName: string,
72+
): boolean {
73+
const projectMatches = stored?.projectName === projectName;
74+
return (
75+
projectMatches &&
76+
stored?.testFiles &&
77+
Object.values(stored.testFiles).some((status) => status === true)
78+
);
79+
}
80+
81+
function getUpdatedFiles(stored: any): string[] {
82+
const updatedFiles: string[] = [];
83+
const fileStatusMap = stored.testFiles;
84+
85+
Object.entries(fileStatusMap).forEach(([filePath, status]) => {
86+
if (status === true) {
87+
updatedFiles.push(filePath);
88+
}
89+
});
90+
91+
return updatedFiles;
92+
}
93+
94+
function generateUpdatedFilesSteps(
95+
stored: any,
96+
updatedFiles: string[],
97+
): string[] {
98+
const filesToRun = updatedFiles.map(toAbs).filter(Boolean) as string[];
99+
const { detectedLanguage, detectedTestingFramework } = stored;
100+
const exampleCommand = getFrameworkTestCommand(
101+
detectedLanguage,
102+
detectedTestingFramework,
103+
);
104+
105+
return [
106+
`Run only the updated files with Percy:\n` +
107+
`Example: ${exampleCommand} <file1> <file2> ...`,
108+
`Updated files to run:\n${filesToRun.join("\n")}`,
109+
];
110+
}
111+
112+
function generateInstructionSteps(instruction: string): string[] {
113+
return [
114+
`Use the provided test command with Percy:\n${instruction}`,
115+
`If this command fails or is incorrect, fall back to the default approach below.`,
116+
];
117+
}

src/tools/sdk-utils/common/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export const PERCY_REPLACE_REGEX =
1919
/Invoke listTestFiles\(\) with the provided directories[\s\S]*?- DO NOT STOP until you add commands in all the files or you reach end of the files\./;
2020

2121
export const PERCY_SNAPSHOT_INSTRUCTION = `
22-
Invoke listTestFiles() with the provided directories from user to gather all test files in memory and obtain the generated UUID ---STEP---
22+
Invoke listTestFiles() with the provided directories from user to gather all test files in memory ---STEP---
2323
Process files in STRICT sequential order using tool addPercySnapshotCommands() with below instructions:
2424
- Start with index 0
2525
- Then index 1

src/tools/sdk-utils/common/schema.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,16 @@ export const SetUpPercyParamsShape = {
3636
),
3737
folderPaths: z
3838
.array(z.string())
39+
.optional()
3940
.describe(
4041
"An array of absolute folder paths containing UI test files. If not provided, analyze codebase for UI test folders by scanning for test patterns which contain UI test cases as per framework. Return empty array if none found.",
4142
),
43+
filePaths: z
44+
.array(z.string())
45+
.optional()
46+
.describe(
47+
"An array of absolute file paths to specific UI test files. Use this when you want to target specific test files rather than entire folders. If not provided, will use folderPaths instead.",
48+
),
4249
};
4350

4451
export const RunTestsOnBrowserStackParamsShape = {

0 commit comments

Comments
 (0)