Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,4 @@ process.on("exit", () => {
export { setLogger } from "./logger.js";
export { BrowserStackMcpServer } from "./server-factory.js";
export { trackMCP } from "./lib/instrumentation.js";
export const PackageJsonVersion = packageJson.version;
13 changes: 12 additions & 1 deletion src/lib/inmemory-store.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,13 @@
export const signedUrlMap = new Map<string, object>();
export const testFilePathsMap = new Map<string, string[]>();

let _storedPercyResults: any = null;

export const storedPercyResults = {
get: () => _storedPercyResults,
set: (value: any) => {
_storedPercyResults = value;
},
clear: () => {
_storedPercyResults = null;
},
};
24 changes: 16 additions & 8 deletions src/tools/add-percy-snapshots.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,39 @@
import { testFilePathsMap } from "../lib/inmemory-store.js";
import { storedPercyResults } from "../lib/inmemory-store.js";
import { updateFileAndStep } from "./percy-snapshot-utils/utils.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { percyWebSetupInstructions } from "../tools/sdk-utils/percy-web/handler.js";

export async function updateTestsWithPercyCommands(args: {
uuid: string;
index: number;
}): Promise<CallToolResult> {
const { uuid, index } = args;
const filePaths = testFilePathsMap.get(uuid);

if (!filePaths) {
throw new Error(`No test files found in memory for UUID: ${uuid}`);
const { index } = args;
const stored = storedPercyResults.get();
if (!stored || !stored.testFiles) {
throw new Error(
`No test files found in memory. Please call listTestFiles first.`,
);
}

const fileStatusMap = stored.testFiles;
const filePaths = Object.keys(fileStatusMap);

if (index < 0 || index >= filePaths.length) {
throw new Error(
`Invalid index: ${index}. There are ${filePaths.length} files for UUID: ${uuid}`,
`Invalid index: ${index}. There are ${filePaths.length} files available.`,
);
}

const result = await updateFileAndStep(
filePaths[index],
index,
filePaths.length,
percyWebSetupInstructions,
);

const updatedStored = { ...stored };
updatedStored.testFiles[filePaths[index]] = true; // true = updated
storedPercyResults.set(updatedStored);

return {
content: result,
};
Expand Down
6 changes: 3 additions & 3 deletions src/tools/automate-utils/fetch-screenshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ async function convertUrlsToBase64(
): Promise<Array<{ url: string; base64: string }>> {
const screenshots = await Promise.all(
urls.map(async (url) => {
const response = await apiClient.get({
url,
responseType: "arraybuffer"
const response = await apiClient.get({
url,
responseType: "arraybuffer",
});
// Axios returns response.data as a Buffer for binary data
const base64 = Buffer.from(response.data).toString("base64");
Expand Down
76 changes: 56 additions & 20 deletions src/tools/list-test-files.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,80 @@
import { listTestFiles } from "./percy-snapshot-utils/detect-test-files.js";
import { testFilePathsMap } from "../lib/inmemory-store.js";
import crypto from "crypto";
import { storedPercyResults } from "../lib/inmemory-store.js";
import { updateFileAndStep } from "./percy-snapshot-utils/utils.js";
import { percyWebSetupInstructions } from "./sdk-utils/percy-web/handler.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";

export async function addListTestFiles(args: any): Promise<CallToolResult> {
const { dirs, language, framework } = args;
let testFiles: string[] = [];

if (!dirs || dirs.length === 0) {
export async function addListTestFiles(): Promise<CallToolResult> {
const storedResults = storedPercyResults.get();
if (!storedResults) {
throw new Error(
"No directories provided to add the test files. Please provide test directories to add percy snapshot commands.",
"No Framework details found. Please call expandPercyVisualTesting first to fetch the framework details.",
);
}

for (const dir of dirs) {
const files = await listTestFiles({
language,
framework,
baseDir: dir,
});
const language = storedResults.detectedLanguage;
const framework = storedResults.detectedTestingFramework;

// Use stored paths from setUpPercy
const dirs = storedResults.folderPaths;
const files = storedResults.filePaths;

let testFiles: string[] = [];

if (files && files.length > 0) {
testFiles = testFiles.concat(files);
}

if (dirs && dirs.length > 0) {
for (const dir of dirs) {
const discoveredFiles = await listTestFiles({
language,
framework,
baseDir: dir,
});
testFiles = testFiles.concat(discoveredFiles);
}
}

// Validate that we have at least one test file
if (testFiles.length === 0) {
throw new Error("No test files found");
throw new Error(
"No test files found. Please provide either specific file paths (files) or directory paths (dirs) containing test files.",
);
}

// Generate a UUID and store the test files in memory
const uuid = crypto.randomUUID();
testFilePathsMap.set(uuid, testFiles);
if (testFiles.length === 1) {
const result = await updateFileAndStep(
testFiles[0],
0,
1,
percyWebSetupInstructions,
);
return {
content: result,
};
}

// For multiple files, store directly in testFiles
const fileStatusMap: { [key: string]: boolean } = {};
testFiles.forEach((file) => {
fileStatusMap[file] = false; // false = not updated, true = updated
});

// Update storedPercyResults with test files
const updatedStored = { ...storedResults };
updatedStored.testFiles = fileStatusMap;
storedPercyResults.set(updatedStored);

return {
content: [
{
type: "text",
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.`,
text: `The Test files are stored in memory and the total number of tests files found is ${testFiles.length}.`,
},
{
type: "text",
text: `You can now use the tool addPercySnapshotCommands to update the test file with Percy commands for visual testing with the UUID ${uuid}`,
text: `You can now use the tool addPercySnapshotCommands to update the test file with Percy commands for visual testing.`,
},
],
};
Expand Down
12 changes: 4 additions & 8 deletions src/tools/percy-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,7 @@ import {
PERCY_SNAPSHOT_COMMANDS_DESCRIPTION,
SIMULATE_PERCY_CHANGE_DESCRIPTION,
} from "./sdk-utils/common/constants.js";

import {
ListTestFilesParamsShape,
UpdateTestFileWithInstructionsParams,
} from "./percy-snapshot-utils/constants.js";
import { UpdateTestFileWithInstructionsParams } from "./percy-snapshot-utils/constants.js";

import {
RunPercyScanParamsShape,
Expand Down Expand Up @@ -126,11 +122,11 @@ export function registerPercyTools(
tools.listTestFiles = server.tool(
"listTestFiles",
LIST_TEST_FILES_DESCRIPTION,
ListTestFilesParamsShape,
async (args) => {
{},
async () => {
try {
trackMCP("listTestFiles", server.server.getClientVersion()!, config);
return addListTestFiles(args);
return addListTestFiles();
} catch (error) {
return handleMCPError("listTestFiles", server, config, error);
}
Expand Down
19 changes: 0 additions & 19 deletions src/tools/percy-snapshot-utils/constants.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,11 @@
import { z } from "zod";
import {
SDKSupportedLanguages,
SDKSupportedTestingFrameworks,
} from "../sdk-utils/common/types.js";
import { SDKSupportedLanguage } from "../sdk-utils/common/types.js";
import { DetectionConfig } from "./types.js";

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

export const ListTestFilesParamsShape = {
dirs: z
.array(z.string())
.describe("Array of directory paths to search for test files"),
language: z
.enum(SDKSupportedLanguages as [string, ...string[]])
.describe("Programming language"),
framework: z
.enum(SDKSupportedTestingFrameworks as [string, ...string[]])
.describe("Testing framework (optional)"),
};

export const TEST_FILE_DETECTION: Record<
SDKSupportedLanguage,
DetectionConfig
Expand Down
94 changes: 75 additions & 19 deletions src/tools/run-percy-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import { PercyIntegrationTypeEnum } from "./sdk-utils/common/types.js";
import { BrowserStackConfig } from "../lib/types.js";
import { getBrowserStackAuth } from "../lib/get-auth.js";
import { fetchPercyToken } from "./sdk-utils/percy-web/fetchPercyToken.js";
import { storedPercyResults } from "../lib/inmemory-store.js";
import {
getFrameworkTestCommand,
PERCY_FALLBACK_STEPS,
} from "./sdk-utils/percy-web/constants.js";
import path from "path";

export async function runPercyScan(
args: {
Expand All @@ -18,25 +24,22 @@ export async function runPercyScan(
type: integrationType,
});

const steps: string[] = [generatePercyTokenInstructions(percyToken)];

if (instruction) {
steps.push(
`Use the provided test command with Percy:\n${instruction}`,
`If this command fails or is incorrect, fall back to the default approach below.`,
);
}

steps.push(
`Attempt to infer the project's test command from context (high confidence commands first):
- Java → mvn test
- Python → pytest
- Node.js → npm test or yarn test
- Cypress → cypress run
or from package.json scripts`,
`Wrap the inferred command with Percy along with label: \nnpx percy exec --labels=mcp -- <test command>`,
`If the test command cannot be inferred confidently, ask the user directly for the correct test command.`,
);
// Check if we have stored data and project matches
const stored = storedPercyResults.get();

// Compute if we have updated files to run
const hasUpdatedFiles = checkForUpdatedFiles(stored, projectName);
const updatedFiles = hasUpdatedFiles ? getUpdatedFiles(stored) : [];

// Build steps array with conditional spread
const steps = [
generatePercyTokenInstructions(percyToken),
...(hasUpdatedFiles ? generateUpdatedFilesSteps(stored, updatedFiles) : []),
...(instruction && !hasUpdatedFiles
? generateInstructionSteps(instruction)
: []),
...(!hasUpdatedFiles ? PERCY_FALLBACK_STEPS : []),
];

const instructionContext = steps
.map((step, index) => `${index + 1}. ${step}`)
Expand All @@ -59,3 +62,56 @@ export PERCY_TOKEN="${percyToken}"

(For Windows: use 'setx PERCY_TOKEN "${percyToken}"' or 'set PERCY_TOKEN=${percyToken}' as appropriate.)`;
}

const toAbs = (p: string): string | undefined =>
p ? path.resolve(p) : undefined;

function checkForUpdatedFiles(
stored: any, // storedPercyResults structure
projectName: string,
): boolean {
const projectMatches = stored?.projectName === projectName;
return (
projectMatches &&
stored?.testFiles &&
Object.values(stored.testFiles).some((status) => status === true)
);
}

function getUpdatedFiles(stored: any): string[] {
const updatedFiles: string[] = [];
const fileStatusMap = stored.testFiles;

Object.entries(fileStatusMap).forEach(([filePath, status]) => {
if (status === true) {
updatedFiles.push(filePath);
}
});

return updatedFiles;
}

function generateUpdatedFilesSteps(
stored: any,
updatedFiles: string[],
): string[] {
const filesToRun = updatedFiles.map(toAbs).filter(Boolean) as string[];
const { detectedLanguage, detectedTestingFramework } = stored;
const exampleCommand = getFrameworkTestCommand(
detectedLanguage,
detectedTestingFramework,
);

return [
`Run only the updated files with Percy:\n` +
`Example: ${exampleCommand} <file1> <file2> ...`,
`Updated files to run:\n${filesToRun.join("\n")}`,
];
}

function generateInstructionSteps(instruction: string): string[] {
return [
`Use the provided test command with Percy:\n${instruction}`,
`If this command fails or is incorrect, fall back to the default approach below.`,
];
}
2 changes: 1 addition & 1 deletion src/tools/sdk-utils/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const PERCY_REPLACE_REGEX =
/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\./;

export const PERCY_SNAPSHOT_INSTRUCTION = `
Invoke listTestFiles() with the provided directories from user to gather all test files in memory and obtain the generated UUID ---STEP---
Invoke listTestFiles() with the provided directories from user to gather all test files in memory ---STEP---
Process files in STRICT sequential order using tool addPercySnapshotCommands() with below instructions:
- Start with index 0
- Then index 1
Expand Down
7 changes: 7 additions & 0 deletions src/tools/sdk-utils/common/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,16 @@ export const SetUpPercyParamsShape = {
),
folderPaths: z
.array(z.string())
.optional()
.describe(
"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.",
),
filePaths: z
.array(z.string())
.optional()
.describe(
"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.",
),
};

export const RunTestsOnBrowserStackParamsShape = {
Expand Down
Loading