Skip to content

Commit 9340833

Browse files
committed
feat: Refactor in-memory storage for Percy results and enhance test file management
1 parent e17c31b commit 9340833

File tree

6 files changed

+163
-26
lines changed

6 files changed

+163
-26
lines changed

src/lib/inmemory-store.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
export const signedUrlMap = new Map<string, object>();
2-
export const testFilePathsMap = new Map<string, string[]>();
32

43
let _storedPercyResults: any = null;
54

@@ -8,4 +7,7 @@ export const storedPercyResults = {
87
set: (value: any) => {
98
_storedPercyResults = value;
109
},
10+
clear: () => {
11+
_storedPercyResults = null;
12+
},
1113
};

src/tools/add-percy-snapshots.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
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";
@@ -8,24 +8,33 @@ export async function updateTestsWithPercyCommands(args: {
88
index: number;
99
}): Promise<CallToolResult> {
1010
const { uuid, index } = args;
11-
const filePaths = testFilePathsMap.get(uuid);
11+
const stored = storedPercyResults.get();
1212

13-
if (!filePaths) {
13+
if (!stored || !stored.uuid || stored.uuid !== uuid || !stored[uuid]) {
1414
throw new Error(`No test files found in memory for UUID: ${uuid}`);
1515
}
1616

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

33+
// Mark this file as updated (true) in the unified structure
34+
const updatedStored = { ...stored };
35+
updatedStored[uuid][filePaths[index]] = true; // true = updated
36+
storedPercyResults.set(updatedStored);
37+
2938
return {
3039
content: result,
3140
};

src/tools/list-test-files.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { listTestFiles } from "./percy-snapshot-utils/detect-test-files.js";
2-
import { testFilePathsMap, storedPercyResults } from "../lib/inmemory-store.js";
2+
import { storedPercyResults } from "../lib/inmemory-store.js";
33
import { updateFileAndStep } from "./percy-snapshot-utils/utils.js";
44
import { percyWebSetupInstructions } from "./sdk-utils/percy-web/handler.js";
55
import crypto from "crypto";
@@ -45,15 +45,31 @@ export async function addListTestFiles(): Promise<CallToolResult> {
4545
}
4646

4747
if (testFiles.length === 1) {
48-
const result = await updateFileAndStep(testFiles[0],0,1,percyWebSetupInstructions);
48+
const result = await updateFileAndStep(
49+
testFiles[0],
50+
0,
51+
1,
52+
percyWebSetupInstructions,
53+
);
4954
return {
5055
content: result,
5156
};
5257
}
5358

5459
// For multiple files, use the UUID workflow
5560
const uuid = crypto.randomUUID();
56-
testFilePathsMap.set(uuid, testFiles);
61+
62+
// Store files in the unified structure with initial status false (not updated)
63+
const fileStatusMap: { [key: string]: boolean } = {};
64+
testFiles.forEach((file) => {
65+
fileStatusMap[file] = false; // false = not updated, true = updated
66+
});
67+
68+
// Update storedPercyResults with single UUID for the project
69+
const updatedStored = { ...storedResults };
70+
updatedStored.uuid = uuid; // Store the UUID reference
71+
updatedStored[uuid] = fileStatusMap; // Store files under the UUID key
72+
storedPercyResults.set(updatedStored);
5773

5874
return {
5975
content: [

src/tools/run-percy-scan.ts

Lines changed: 76 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,57 @@ 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?.uuid &&
77+
stored[stored.uuid] &&
78+
Object.values(stored[stored.uuid]).some((status) => status === true)
79+
);
80+
}
81+
82+
function getUpdatedFiles(stored: any): string[] {
83+
const updatedFiles: string[] = [];
84+
const fileStatusMap = stored[stored.uuid!];
85+
86+
Object.entries(fileStatusMap).forEach(([filePath, status]) => {
87+
if (status === true) {
88+
updatedFiles.push(filePath);
89+
}
90+
});
91+
92+
return updatedFiles;
93+
}
94+
95+
function generateUpdatedFilesSteps(
96+
stored: any,
97+
updatedFiles: string[],
98+
): string[] {
99+
const filesToRun = updatedFiles.map(toAbs).filter(Boolean) as string[];
100+
const { detectedLanguage, detectedTestingFramework } = stored;
101+
const exampleCommand = getFrameworkTestCommand(
102+
detectedLanguage,
103+
detectedTestingFramework,
104+
);
105+
106+
return [
107+
`Run only the updated files with Percy:\n` +
108+
`Example: ${exampleCommand} -- <file1> <file2> ...`,
109+
`Updated files to run:\n${filesToRun.join("\n")}`,
110+
];
111+
}
112+
113+
function generateInstructionSteps(instruction: string): string[] {
114+
return [
115+
`Use the provided test command with Percy:\n${instruction}`,
116+
`If this command fails or is incorrect, fall back to the default approach below.`,
117+
];
118+
}

src/tools/sdk-utils/handler.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,19 @@ export async function setUpPercyHandler(
4545
const input = SetUpPercySchema.parse(rawInput);
4646
validatePercyPathandFolders(input);
4747

48+
// Clear any previous Percy results for a fresh start
49+
storedPercyResults.clear();
50+
4851
storedPercyResults.set({
52+
projectName: input.projectName,
4953
detectedLanguage: input.detectedLanguage,
5054
detectedBrowserAutomationFramework:
5155
input.detectedBrowserAutomationFramework,
5256
detectedTestingFramework: input.detectedTestingFramework,
5357
integrationType: input.integrationType,
5458
folderPaths: input.folderPaths || [],
5559
filePaths: input.filePaths || [],
60+
uuid: null,
5661
});
5762

5863
const authorization = getBrowserStackAuth(config);

src/tools/sdk-utils/percy-web/constants.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -921,3 +921,51 @@ ${csharpPlaywrightInstructionsSnapshot}
921921
To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g. npx percy exec -- <command to run the test script file>).
922922
${percyReviewSnapshotsStep}
923923
`;
924+
925+
export function getFrameworkTestCommand(
926+
language: string,
927+
framework: string,
928+
): string {
929+
const percyPrefix = "npx percy exec --labels=mcp --";
930+
931+
const nodeCommands: Record<string, string> = {
932+
cypress: "cypress run",
933+
playwright: "playwright test",
934+
webdriverio: "wdio",
935+
puppeteer: "node",
936+
testcafe: "testcafe",
937+
nightwatch: "nightwatch",
938+
protractor: "protractor",
939+
gatsby: "gatsby build",
940+
storybook: "storybook build",
941+
ember: "ember test",
942+
default: "npm test",
943+
};
944+
945+
const languageMap: Record<string, string> = {
946+
python: "python -m pytest",
947+
java: "mvn test",
948+
ruby: "bundle exec rspec",
949+
csharp: "dotnet test",
950+
};
951+
952+
if (language === "nodejs") {
953+
const cmd = nodeCommands[framework] ?? nodeCommands.default;
954+
return `${percyPrefix} ${cmd}`;
955+
}
956+
957+
const cmd = languageMap[language];
958+
return cmd ? `${percyPrefix} ${cmd}` : `${percyPrefix} <test-runner>`;
959+
}
960+
961+
export const PERCY_FALLBACK_STEPS = [
962+
`Attempt to infer the project's test command from context (high confidence commands first):
963+
- Node.js: npm test, cypress run, npx playwright test, npx wdio, npx testcafe, npx nightwatch, npx protractor, ember test, npx gatsby build, npx storybook build
964+
- Python: python -m pytest
965+
- Java: mvn test
966+
- Ruby: bundle exec rspec
967+
- C#: dotnet test
968+
or from package.json scripts`,
969+
`Wrap the inferred command with Percy along with label: \nnpx percy exec --labels=mcp -- <test command>`,
970+
`If the test command cannot be inferred confidently, ask the user directly for the correct test command.`,
971+
];

0 commit comments

Comments
 (0)