@browserstack/mcp-server 1.2.15-beta.1 → 1.2.15-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/percy-api/auth.d.ts +41 -0
- package/dist/lib/percy-api/auth.js +96 -0
- package/dist/lib/percy-api/cache.d.ts +28 -0
- package/dist/lib/percy-api/cache.js +48 -0
- package/dist/lib/percy-api/client.d.ts +69 -0
- package/dist/lib/percy-api/client.js +275 -0
- package/dist/lib/percy-api/errors.d.ts +15 -0
- package/dist/lib/percy-api/errors.js +52 -0
- package/dist/lib/percy-api/formatter.d.ts +16 -0
- package/dist/lib/percy-api/formatter.js +344 -0
- package/dist/lib/percy-api/percy-auth.d.ts +43 -0
- package/dist/lib/percy-api/percy-auth.js +137 -0
- package/dist/lib/percy-api/percy-error-handler.d.ts +24 -0
- package/dist/lib/percy-api/percy-error-handler.js +302 -0
- package/dist/lib/percy-api/percy-session.d.ts +42 -0
- package/dist/lib/percy-api/percy-session.js +87 -0
- package/dist/lib/percy-api/polling.d.ts +26 -0
- package/dist/lib/percy-api/polling.js +42 -0
- package/dist/lib/percy-api/types.d.ts +56 -0
- package/dist/lib/percy-api/types.js +76 -0
- package/dist/server-factory.js +4 -0
- package/dist/tools/percy-mcp/advanced/branchline-operations.d.ts +16 -0
- package/dist/tools/percy-mcp/advanced/branchline-operations.js +81 -0
- package/dist/tools/percy-mcp/advanced/manage-variants.d.ts +16 -0
- package/dist/tools/percy-mcp/advanced/manage-variants.js +155 -0
- package/dist/tools/percy-mcp/advanced/manage-visual-monitoring.d.ts +16 -0
- package/dist/tools/percy-mcp/advanced/manage-visual-monitoring.js +171 -0
- package/dist/tools/percy-mcp/auth/auth-status.d.ts +3 -0
- package/dist/tools/percy-mcp/auth/auth-status.js +131 -0
- package/dist/tools/percy-mcp/core/approve-build.d.ts +14 -0
- package/dist/tools/percy-mcp/core/approve-build.js +97 -0
- package/dist/tools/percy-mcp/core/get-build-items.d.ts +13 -0
- package/dist/tools/percy-mcp/core/get-build-items.js +65 -0
- package/dist/tools/percy-mcp/core/get-build.d.ts +10 -0
- package/dist/tools/percy-mcp/core/get-build.js +16 -0
- package/dist/tools/percy-mcp/core/get-comparison.d.ts +11 -0
- package/dist/tools/percy-mcp/core/get-comparison.js +59 -0
- package/dist/tools/percy-mcp/core/get-snapshot.d.ts +10 -0
- package/dist/tools/percy-mcp/core/get-snapshot.js +40 -0
- package/dist/tools/percy-mcp/core/list-builds.d.ts +14 -0
- package/dist/tools/percy-mcp/core/list-builds.js +45 -0
- package/dist/tools/percy-mcp/core/list-projects.d.ts +12 -0
- package/dist/tools/percy-mcp/core/list-projects.js +51 -0
- package/dist/tools/percy-mcp/creation/create-app-snapshot.d.ts +12 -0
- package/dist/tools/percy-mcp/creation/create-app-snapshot.js +29 -0
- package/dist/tools/percy-mcp/creation/create-build.d.ts +19 -0
- package/dist/tools/percy-mcp/creation/create-build.js +68 -0
- package/dist/tools/percy-mcp/creation/create-comparison.d.ts +18 -0
- package/dist/tools/percy-mcp/creation/create-comparison.js +90 -0
- package/dist/tools/percy-mcp/creation/create-snapshot.d.ts +17 -0
- package/dist/tools/percy-mcp/creation/create-snapshot.js +99 -0
- package/dist/tools/percy-mcp/creation/finalize-build.d.ts +12 -0
- package/dist/tools/percy-mcp/creation/finalize-build.js +33 -0
- package/dist/tools/percy-mcp/creation/finalize-comparison.d.ts +10 -0
- package/dist/tools/percy-mcp/creation/finalize-comparison.js +16 -0
- package/dist/tools/percy-mcp/creation/finalize-snapshot.d.ts +12 -0
- package/dist/tools/percy-mcp/creation/finalize-snapshot.js +33 -0
- package/dist/tools/percy-mcp/creation/upload-resource.d.ts +15 -0
- package/dist/tools/percy-mcp/creation/upload-resource.js +43 -0
- package/dist/tools/percy-mcp/creation/upload-tile.d.ts +11 -0
- package/dist/tools/percy-mcp/creation/upload-tile.js +53 -0
- package/dist/tools/percy-mcp/diagnostics/analyze-logs-realtime.d.ts +13 -0
- package/dist/tools/percy-mcp/diagnostics/analyze-logs-realtime.js +65 -0
- package/dist/tools/percy-mcp/diagnostics/get-build-logs.d.ts +17 -0
- package/dist/tools/percy-mcp/diagnostics/get-build-logs.js +74 -0
- package/dist/tools/percy-mcp/diagnostics/get-network-logs.d.ts +5 -0
- package/dist/tools/percy-mcp/diagnostics/get-network-logs.js +21 -0
- package/dist/tools/percy-mcp/diagnostics/get-suggestions.d.ts +7 -0
- package/dist/tools/percy-mcp/diagnostics/get-suggestions.js +24 -0
- package/dist/tools/percy-mcp/index.d.ts +36 -0
- package/dist/tools/percy-mcp/index.js +1137 -0
- package/dist/tools/percy-mcp/intelligence/get-ai-analysis.d.ts +15 -0
- package/dist/tools/percy-mcp/intelligence/get-ai-analysis.js +166 -0
- package/dist/tools/percy-mcp/intelligence/get-ai-quota.d.ts +9 -0
- package/dist/tools/percy-mcp/intelligence/get-ai-quota.js +73 -0
- package/dist/tools/percy-mcp/intelligence/get-build-summary.d.ts +11 -0
- package/dist/tools/percy-mcp/intelligence/get-build-summary.js +78 -0
- package/dist/tools/percy-mcp/intelligence/get-rca.d.ts +6 -0
- package/dist/tools/percy-mcp/intelligence/get-rca.js +153 -0
- package/dist/tools/percy-mcp/intelligence/suggest-prompt.d.ts +15 -0
- package/dist/tools/percy-mcp/intelligence/suggest-prompt.js +86 -0
- package/dist/tools/percy-mcp/intelligence/trigger-ai-recompute.d.ts +16 -0
- package/dist/tools/percy-mcp/intelligence/trigger-ai-recompute.js +64 -0
- package/dist/tools/percy-mcp/management/create-project.d.ts +14 -0
- package/dist/tools/percy-mcp/management/create-project.js +52 -0
- package/dist/tools/percy-mcp/management/get-usage-stats.d.ts +12 -0
- package/dist/tools/percy-mcp/management/get-usage-stats.js +61 -0
- package/dist/tools/percy-mcp/management/manage-browser-targets.d.ts +12 -0
- package/dist/tools/percy-mcp/management/manage-browser-targets.js +136 -0
- package/dist/tools/percy-mcp/management/manage-comments.d.ts +14 -0
- package/dist/tools/percy-mcp/management/manage-comments.js +147 -0
- package/dist/tools/percy-mcp/management/manage-ignored-regions.d.ts +18 -0
- package/dist/tools/percy-mcp/management/manage-ignored-regions.js +182 -0
- package/dist/tools/percy-mcp/management/manage-project-settings.d.ts +16 -0
- package/dist/tools/percy-mcp/management/manage-project-settings.js +97 -0
- package/dist/tools/percy-mcp/management/manage-tokens.d.ts +14 -0
- package/dist/tools/percy-mcp/management/manage-tokens.js +90 -0
- package/dist/tools/percy-mcp/management/manage-webhooks.d.ts +15 -0
- package/dist/tools/percy-mcp/management/manage-webhooks.js +180 -0
- package/dist/tools/percy-mcp/v2/auth-status.d.ts +3 -0
- package/dist/tools/percy-mcp/v2/auth-status.js +80 -0
- package/dist/tools/percy-mcp/v2/clone-build.d.ts +24 -0
- package/dist/tools/percy-mcp/v2/clone-build.js +539 -0
- package/dist/tools/percy-mcp/v2/create-app-build.d.ts +28 -0
- package/dist/tools/percy-mcp/v2/create-app-build.js +442 -0
- package/dist/tools/percy-mcp/v2/create-build.d.ts +16 -0
- package/dist/tools/percy-mcp/v2/create-build.js +601 -0
- package/dist/tools/percy-mcp/v2/create-project.d.ts +8 -0
- package/dist/tools/percy-mcp/v2/create-project.js +33 -0
- package/dist/tools/percy-mcp/v2/discover-urls.d.ts +7 -0
- package/dist/tools/percy-mcp/v2/discover-urls.js +38 -0
- package/dist/tools/percy-mcp/v2/figma-baseline.d.ts +7 -0
- package/dist/tools/percy-mcp/v2/figma-baseline.js +18 -0
- package/dist/tools/percy-mcp/v2/figma-build.d.ts +7 -0
- package/dist/tools/percy-mcp/v2/figma-build.js +39 -0
- package/dist/tools/percy-mcp/v2/figma-link.d.ts +6 -0
- package/dist/tools/percy-mcp/v2/figma-link.js +27 -0
- package/dist/tools/percy-mcp/v2/get-ai-summary.d.ts +5 -0
- package/dist/tools/percy-mcp/v2/get-ai-summary.js +109 -0
- package/dist/tools/percy-mcp/v2/get-build-detail.d.ts +22 -0
- package/dist/tools/percy-mcp/v2/get-build-detail.js +567 -0
- package/dist/tools/percy-mcp/v2/get-builds.d.ts +8 -0
- package/dist/tools/percy-mcp/v2/get-builds.js +63 -0
- package/dist/tools/percy-mcp/v2/get-comparison.d.ts +5 -0
- package/dist/tools/percy-mcp/v2/get-comparison.js +94 -0
- package/dist/tools/percy-mcp/v2/get-devices.d.ts +5 -0
- package/dist/tools/percy-mcp/v2/get-devices.js +33 -0
- package/dist/tools/percy-mcp/v2/get-insights.d.ts +7 -0
- package/dist/tools/percy-mcp/v2/get-insights.js +52 -0
- package/dist/tools/percy-mcp/v2/get-projects.d.ts +6 -0
- package/dist/tools/percy-mcp/v2/get-projects.js +41 -0
- package/dist/tools/percy-mcp/v2/get-snapshot.d.ts +5 -0
- package/dist/tools/percy-mcp/v2/get-snapshot.js +96 -0
- package/dist/tools/percy-mcp/v2/get-test-case-history.d.ts +5 -0
- package/dist/tools/percy-mcp/v2/get-test-case-history.js +20 -0
- package/dist/tools/percy-mcp/v2/get-test-cases.d.ts +6 -0
- package/dist/tools/percy-mcp/v2/get-test-cases.js +36 -0
- package/dist/tools/percy-mcp/v2/index.d.ts +35 -0
- package/dist/tools/percy-mcp/v2/index.js +544 -0
- package/dist/tools/percy-mcp/v2/list-integrations.d.ts +5 -0
- package/dist/tools/percy-mcp/v2/list-integrations.js +41 -0
- package/dist/tools/percy-mcp/v2/manage-domains.d.ts +8 -0
- package/dist/tools/percy-mcp/v2/manage-domains.js +33 -0
- package/dist/tools/percy-mcp/v2/manage-insights-email.d.ts +8 -0
- package/dist/tools/percy-mcp/v2/manage-insights-email.js +49 -0
- package/dist/tools/percy-mcp/v2/manage-usage-alerts.d.ts +10 -0
- package/dist/tools/percy-mcp/v2/manage-usage-alerts.js +43 -0
- package/dist/tools/percy-mcp/v2/migrate-integrations.d.ts +6 -0
- package/dist/tools/percy-mcp/v2/migrate-integrations.js +20 -0
- package/dist/tools/percy-mcp/v2/preview-comparison.d.ts +5 -0
- package/dist/tools/percy-mcp/v2/preview-comparison.js +17 -0
- package/dist/tools/percy-mcp/v2/search-build-items.d.ts +12 -0
- package/dist/tools/percy-mcp/v2/search-build-items.js +45 -0
- package/dist/tools/percy-mcp/workflows/auto-triage.d.ts +7 -0
- package/dist/tools/percy-mcp/workflows/auto-triage.js +82 -0
- package/dist/tools/percy-mcp/workflows/clone-build.d.ts +22 -0
- package/dist/tools/percy-mcp/workflows/clone-build.js +414 -0
- package/dist/tools/percy-mcp/workflows/create-percy-build.d.ts +32 -0
- package/dist/tools/percy-mcp/workflows/create-percy-build.js +434 -0
- package/dist/tools/percy-mcp/workflows/debug-failed-build.d.ts +5 -0
- package/dist/tools/percy-mcp/workflows/debug-failed-build.js +122 -0
- package/dist/tools/percy-mcp/workflows/diff-explain.d.ts +6 -0
- package/dist/tools/percy-mcp/workflows/diff-explain.js +147 -0
- package/dist/tools/percy-mcp/workflows/pr-visual-report.d.ts +8 -0
- package/dist/tools/percy-mcp/workflows/pr-visual-report.js +184 -0
- package/dist/tools/percy-mcp/workflows/run-tests.d.ts +17 -0
- package/dist/tools/percy-mcp/workflows/run-tests.js +107 -0
- package/dist/tools/percy-mcp/workflows/snapshot-urls.d.ts +18 -0
- package/dist/tools/percy-mcp/workflows/snapshot-urls.js +197 -0
- package/package.json +3 -2
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* percy_clone_build — Clone a build into a different project with 100% parity.
|
|
3
|
+
*
|
|
4
|
+
* Preserves: snapshot names, all widths, all browsers, all device info.
|
|
5
|
+
*
|
|
6
|
+
* Two modes (auto-selected):
|
|
7
|
+
* 1. URL Replay (`percy snapshot`): URL-named snapshots → full DOM re-render
|
|
8
|
+
* 2. Screenshot Clone (direct API): Named snapshots → downloads all screenshots,
|
|
9
|
+
* re-uploads via tile API. Each snapshot keeps its exact name with all
|
|
10
|
+
* width/browser/device comparisons intact.
|
|
11
|
+
*
|
|
12
|
+
* Screenshot clone uses tile-based API which requires app-type project.
|
|
13
|
+
* If target project is web-type, creates with same name as app-type.
|
|
14
|
+
*/
|
|
15
|
+
import { BrowserStackConfig } from "../../../lib/types.js";
|
|
16
|
+
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
17
|
+
interface CloneBuildArgs {
|
|
18
|
+
source_build_id: string;
|
|
19
|
+
target_project_name: string;
|
|
20
|
+
target_token?: string;
|
|
21
|
+
branch?: string;
|
|
22
|
+
}
|
|
23
|
+
export declare function percyCloneBuildV2(args: CloneBuildArgs, config: BrowserStackConfig): Promise<CallToolResult>;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* percy_clone_build — Clone a build into a different project with 100% parity.
|
|
3
|
+
*
|
|
4
|
+
* Preserves: snapshot names, all widths, all browsers, all device info.
|
|
5
|
+
*
|
|
6
|
+
* Two modes (auto-selected):
|
|
7
|
+
* 1. URL Replay (`percy snapshot`): URL-named snapshots → full DOM re-render
|
|
8
|
+
* 2. Screenshot Clone (direct API): Named snapshots → downloads all screenshots,
|
|
9
|
+
* re-uploads via tile API. Each snapshot keeps its exact name with all
|
|
10
|
+
* width/browser/device comparisons intact.
|
|
11
|
+
*
|
|
12
|
+
* Screenshot clone uses tile-based API which requires app-type project.
|
|
13
|
+
* If target project is web-type, creates with same name as app-type.
|
|
14
|
+
*/
|
|
15
|
+
import { percyGet, percyTokenPost, getOrCreateProjectToken, getPercyAuthHeaders, } from "../../../lib/percy-api/percy-auth.js";
|
|
16
|
+
import { createHash } from "crypto";
|
|
17
|
+
import { execFile, spawn } from "child_process";
|
|
18
|
+
import { promisify } from "util";
|
|
19
|
+
import { writeFile, unlink, mkdtemp } from "fs/promises";
|
|
20
|
+
import { join } from "path";
|
|
21
|
+
import { tmpdir } from "os";
|
|
22
|
+
const execFileAsync = promisify(execFile);
|
|
23
|
+
async function getGitBranch() {
|
|
24
|
+
try {
|
|
25
|
+
return ((await execFileAsync("git", ["branch", "--show-current"])).stdout.trim() || "main");
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return "main";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Strip base64 padding — Percy requires strict base64 (RFC 4648 §4.1) */
|
|
32
|
+
function toStrictBase64(buffer) {
|
|
33
|
+
return buffer.toString("base64").replace(/=+$/, "");
|
|
34
|
+
}
|
|
35
|
+
/** Small delay to avoid rate limiting */
|
|
36
|
+
function delay(ms) {
|
|
37
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
38
|
+
}
|
|
39
|
+
export async function percyCloneBuildV2(args, config) {
|
|
40
|
+
const branch = args.branch || (await getGitBranch());
|
|
41
|
+
let output = `## Percy Build Clone\n\n`;
|
|
42
|
+
output += `**Source:** Build #${args.source_build_id}\n`;
|
|
43
|
+
output += `**Target:** ${args.target_project_name}\n`;
|
|
44
|
+
output += `**Branch:** ${branch}\n\n`;
|
|
45
|
+
// ── Step 1: Read source build ─────────────────────────────────────────
|
|
46
|
+
let sourceBuild;
|
|
47
|
+
try {
|
|
48
|
+
sourceBuild = await percyGet(`/builds/${args.source_build_id}`, config);
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
return {
|
|
52
|
+
content: [
|
|
53
|
+
{ type: "text", text: `Failed to read source build: ${e.message}` },
|
|
54
|
+
],
|
|
55
|
+
isError: true,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const sourceAttrs = sourceBuild?.data?.attributes || {};
|
|
59
|
+
const buildType = sourceAttrs.type || "web";
|
|
60
|
+
output += `Source: **${sourceAttrs.state}** — ${sourceAttrs["total-snapshots"]} snapshots, type: ${buildType}\n\n`;
|
|
61
|
+
// ── Step 2: Get ALL snapshot details with ALL comparisons ─────────────
|
|
62
|
+
const headers = getPercyAuthHeaders(config);
|
|
63
|
+
const baseUrl = "https://percy.io/api/v1";
|
|
64
|
+
let allSnapshotIds = [];
|
|
65
|
+
try {
|
|
66
|
+
const items = await percyGet("/build-items", config, {
|
|
67
|
+
"filter[build-id]": args.source_build_id,
|
|
68
|
+
"page[limit]": "50",
|
|
69
|
+
});
|
|
70
|
+
const itemList = items?.data || [];
|
|
71
|
+
for (const item of itemList) {
|
|
72
|
+
const a = item.attributes || item;
|
|
73
|
+
const ids = a["snapshot-ids"] || a.snapshotIds || [];
|
|
74
|
+
if (ids.length > 0) {
|
|
75
|
+
allSnapshotIds.push(...ids.map(String));
|
|
76
|
+
}
|
|
77
|
+
else if (a["cover-snapshot-id"] || a.coverSnapshotId) {
|
|
78
|
+
allSnapshotIds.push(String(a["cover-snapshot-id"] || a.coverSnapshotId));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
allSnapshotIds = [...new Set(allSnapshotIds)];
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
return {
|
|
85
|
+
content: [
|
|
86
|
+
{ type: "text", text: `Failed to read snapshots: ${e.message}` },
|
|
87
|
+
],
|
|
88
|
+
isError: true,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
output += `Found **${allSnapshotIds.length}** snapshots.\n\n`;
|
|
92
|
+
if (allSnapshotIds.length === 0) {
|
|
93
|
+
output += `Nothing to clone.\n`;
|
|
94
|
+
return { content: [{ type: "text", text: output }] };
|
|
95
|
+
}
|
|
96
|
+
// Read snapshot metadata with ALL comparison details (all browsers, all widths)
|
|
97
|
+
const snapshots = [];
|
|
98
|
+
let totalComps = 0;
|
|
99
|
+
for (const snapId of allSnapshotIds) {
|
|
100
|
+
try {
|
|
101
|
+
const snapResponse = await fetch(`${baseUrl}/snapshots/${snapId}?include=comparisons.head-screenshot.image,comparisons.comparison-tag`, { headers });
|
|
102
|
+
if (!snapResponse.ok)
|
|
103
|
+
continue;
|
|
104
|
+
const snapJson = await snapResponse.json();
|
|
105
|
+
const sa = snapJson.data?.attributes || {};
|
|
106
|
+
const included = snapJson.included || [];
|
|
107
|
+
const byTypeId = new Map();
|
|
108
|
+
for (const item of included) {
|
|
109
|
+
byTypeId.set(`${item.type}:${item.id}`, item);
|
|
110
|
+
}
|
|
111
|
+
const compRefs = snapJson.data?.relationships?.comparisons?.data || [];
|
|
112
|
+
const comparisons = [];
|
|
113
|
+
const widthSet = new Set();
|
|
114
|
+
for (const ref of compRefs) {
|
|
115
|
+
const comp = byTypeId.get(`comparisons:${ref.id}`);
|
|
116
|
+
if (!comp)
|
|
117
|
+
continue;
|
|
118
|
+
const width = comp.attributes?.width || 1280;
|
|
119
|
+
widthSet.add(width);
|
|
120
|
+
let imageUrl = null;
|
|
121
|
+
let height = 800;
|
|
122
|
+
const hsRef = comp.relationships?.["head-screenshot"]?.data;
|
|
123
|
+
if (hsRef) {
|
|
124
|
+
const ss = byTypeId.get(`screenshots:${hsRef.id}`);
|
|
125
|
+
const imgRef = ss?.relationships?.image?.data;
|
|
126
|
+
if (imgRef) {
|
|
127
|
+
const img = byTypeId.get(`images:${imgRef.id}`);
|
|
128
|
+
if (img) {
|
|
129
|
+
imageUrl = img.attributes?.url || null;
|
|
130
|
+
height = img.attributes?.height || 800;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Extract REAL device/browser info from comparison tag
|
|
135
|
+
const tagRef = comp.relationships?.["comparison-tag"]?.data;
|
|
136
|
+
let tagName = "Chrome";
|
|
137
|
+
let osName = "";
|
|
138
|
+
let osVersion = "";
|
|
139
|
+
let browserName = "Chrome";
|
|
140
|
+
let browserVersion = "";
|
|
141
|
+
let orientation = "portrait";
|
|
142
|
+
if (tagRef) {
|
|
143
|
+
const tag = byTypeId.get(`comparison-tags:${tagRef.id}`);
|
|
144
|
+
if (tag?.attributes) {
|
|
145
|
+
const ta = tag.attributes;
|
|
146
|
+
tagName = ta.name || tagName;
|
|
147
|
+
osName = ta["os-name"] || osName;
|
|
148
|
+
osVersion = ta["os-version"] || osVersion;
|
|
149
|
+
browserName = ta["browser-name"] || browserName;
|
|
150
|
+
browserVersion = ta["browser-version"] || browserVersion;
|
|
151
|
+
orientation = ta.orientation || orientation;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
comparisons.push({
|
|
155
|
+
width,
|
|
156
|
+
height,
|
|
157
|
+
tagName,
|
|
158
|
+
osName,
|
|
159
|
+
osVersion,
|
|
160
|
+
browserName,
|
|
161
|
+
browserVersion,
|
|
162
|
+
orientation,
|
|
163
|
+
imageUrl,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
totalComps += comparisons.length;
|
|
167
|
+
snapshots.push({
|
|
168
|
+
id: snapId,
|
|
169
|
+
name: sa.name || `Snapshot ${snapId}`,
|
|
170
|
+
displayName: sa["display-name"] || sa.name || "",
|
|
171
|
+
widths: [...widthSet].sort(),
|
|
172
|
+
enableJavascript: sa["enable-javascript"] || false,
|
|
173
|
+
testCase: sa["test-case-name"] || null,
|
|
174
|
+
comparisons,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
/* skip failed snapshots */
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// Show what we found
|
|
182
|
+
const browsers = new Set();
|
|
183
|
+
const widths = new Set();
|
|
184
|
+
for (const snap of snapshots) {
|
|
185
|
+
for (const c of snap.comparisons) {
|
|
186
|
+
browsers.add(c.browserName || c.tagName);
|
|
187
|
+
widths.add(c.width);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
output += `Read **${snapshots.length}** snapshots, **${totalComps}** comparisons\n`;
|
|
191
|
+
output += `Browsers: ${[...browsers].join(", ")}\n`;
|
|
192
|
+
output += `Widths: ${[...widths].sort((a, b) => a - b).join(", ")}px\n\n`;
|
|
193
|
+
// ── Step 3: Ensure Percy CLI is available (auto-install if missing) ───
|
|
194
|
+
let hasCli = false;
|
|
195
|
+
try {
|
|
196
|
+
await execFileAsync("npx", ["@percy/cli", "--version"]);
|
|
197
|
+
hasCli = true;
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
output += `Installing Percy CLI...\n`;
|
|
201
|
+
try {
|
|
202
|
+
await execFileAsync("npm", ["install", "-g", "@percy/cli"], {
|
|
203
|
+
timeout: 60000,
|
|
204
|
+
});
|
|
205
|
+
hasCli = true;
|
|
206
|
+
output += `Percy CLI installed.\n\n`;
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
hasCli = false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// ── Step 4: Determine clone mode ──────────────────────────────────────
|
|
213
|
+
const hasUrlNames = snapshots.some((s) => s.name.startsWith("http://") || s.name.startsWith("https://"));
|
|
214
|
+
if (hasCli && hasUrlNames) {
|
|
215
|
+
// URL Replay: Percy CLI re-snapshots with full DOM/CSS/JS
|
|
216
|
+
let targetToken;
|
|
217
|
+
if (args.target_token) {
|
|
218
|
+
targetToken = args.target_token;
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
try {
|
|
222
|
+
targetToken = await getOrCreateProjectToken(args.target_project_name, config);
|
|
223
|
+
}
|
|
224
|
+
catch (e) {
|
|
225
|
+
return {
|
|
226
|
+
content: [
|
|
227
|
+
{ type: "text", text: `Failed to get target token: ${e.message}` },
|
|
228
|
+
],
|
|
229
|
+
isError: true,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return await replayWithPercyCli(output, snapshots, targetToken, branch, args.target_project_name);
|
|
234
|
+
}
|
|
235
|
+
// ── Screenshot Clone via tile API ─────────────────────────────────────
|
|
236
|
+
// Tile-based API preserves: exact snapshot names, all widths, all browsers.
|
|
237
|
+
// MUST be app-type project — web projects require DOM resources for snapshots
|
|
238
|
+
// and reject tile-based uploads. This is an immutable Percy API constraint.
|
|
239
|
+
let targetToken;
|
|
240
|
+
const actualProjectName = args.target_project_name;
|
|
241
|
+
if (args.target_token) {
|
|
242
|
+
targetToken = args.target_token;
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
// Always request app type — tiles only work on app/automate/generic projects.
|
|
246
|
+
// If project exists as web-type, this will fail and we retry with a suffix.
|
|
247
|
+
try {
|
|
248
|
+
targetToken = await getOrCreateProjectToken(args.target_project_name, config, "app");
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
// Project exists as web-type — can't use tiles on it.
|
|
252
|
+
// Create companion project with same name + "-screenshots" as app type.
|
|
253
|
+
const altName = `${args.target_project_name}-screenshots`;
|
|
254
|
+
output += `"${args.target_project_name}" is web-type (needs DOM resources).\n`;
|
|
255
|
+
output += `Creating **${altName}** (app-type) for screenshot clone.\n\n`;
|
|
256
|
+
try {
|
|
257
|
+
targetToken = await getOrCreateProjectToken(altName, config, "app");
|
|
258
|
+
}
|
|
259
|
+
catch (e) {
|
|
260
|
+
return {
|
|
261
|
+
content: [
|
|
262
|
+
{ type: "text", text: `Failed to create project: ${e.message}` },
|
|
263
|
+
],
|
|
264
|
+
isError: true,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return await cloneViaApi(output, snapshots, targetToken, branch, actualProjectName, totalComps);
|
|
270
|
+
}
|
|
271
|
+
// ── URL Replay (Percy CLI) ──────────────────────────────────────────────────
|
|
272
|
+
async function replayWithPercyCli(output, snapshots, token, branch, projectName) {
|
|
273
|
+
output += `### Mode: URL Replay (Percy CLI)\n\n`;
|
|
274
|
+
output += `**Project:** ${projectName}\n`;
|
|
275
|
+
output += `Percy CLI will re-snapshot each page with full resource discovery.\n\n`;
|
|
276
|
+
let yamlContent = "";
|
|
277
|
+
const uniqueNames = new Set();
|
|
278
|
+
for (const snap of snapshots) {
|
|
279
|
+
if (uniqueNames.has(snap.name))
|
|
280
|
+
continue;
|
|
281
|
+
uniqueNames.add(snap.name);
|
|
282
|
+
const name = snap.displayName || snap.name;
|
|
283
|
+
const widths = snap.widths.length > 0 ? snap.widths : [1280];
|
|
284
|
+
yamlContent += `- name: "${name}"\n`;
|
|
285
|
+
if (snap.name.startsWith("http://") || snap.name.startsWith("https://")) {
|
|
286
|
+
yamlContent += ` url: ${snap.name}\n`;
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
yamlContent += ` url: "UNKNOWN"\n`;
|
|
290
|
+
}
|
|
291
|
+
yamlContent += ` waitForTimeout: 3000\n`;
|
|
292
|
+
if (snap.enableJavascript) {
|
|
293
|
+
yamlContent += ` enableJavaScript: true\n`;
|
|
294
|
+
}
|
|
295
|
+
if (snap.testCase) {
|
|
296
|
+
yamlContent += ` testCase: "${snap.testCase}"\n`;
|
|
297
|
+
}
|
|
298
|
+
yamlContent += ` widths:\n`;
|
|
299
|
+
widths.forEach((w) => {
|
|
300
|
+
yamlContent += ` - ${w}\n`;
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
const hasUrls = snapshots.some((s) => s.name.startsWith("http://") || s.name.startsWith("https://"));
|
|
304
|
+
if (!hasUrls) {
|
|
305
|
+
output += `Snapshots don't have URL names. Use \`percy_create_build\` with URLs.\n`;
|
|
306
|
+
return { content: [{ type: "text", text: output }] };
|
|
307
|
+
}
|
|
308
|
+
const tmpDir = await mkdtemp(join(tmpdir(), "percy-clone-"));
|
|
309
|
+
const configPath = join(tmpDir, "snapshots.yml");
|
|
310
|
+
await writeFile(configPath, yamlContent);
|
|
311
|
+
const child = spawn("npx", ["@percy/cli", "snapshot", configPath], {
|
|
312
|
+
env: { ...process.env, PERCY_TOKEN: token, PERCY_BRANCH: branch },
|
|
313
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
314
|
+
detached: true,
|
|
315
|
+
});
|
|
316
|
+
let buildUrl = "";
|
|
317
|
+
let stdoutData = "";
|
|
318
|
+
child.stdout?.on("data", (d) => {
|
|
319
|
+
const text = d.toString();
|
|
320
|
+
stdoutData += text;
|
|
321
|
+
const match = text.match(/https:\/\/percy\.io\/[^\s]+\/builds\/\d+/);
|
|
322
|
+
if (match)
|
|
323
|
+
buildUrl = match[0];
|
|
324
|
+
});
|
|
325
|
+
child.stderr?.on("data", (d) => {
|
|
326
|
+
stdoutData += d.toString();
|
|
327
|
+
});
|
|
328
|
+
await new Promise((resolve) => {
|
|
329
|
+
const timeout = setTimeout(resolve, 30000);
|
|
330
|
+
child.on("close", () => {
|
|
331
|
+
clearTimeout(timeout);
|
|
332
|
+
resolve();
|
|
333
|
+
});
|
|
334
|
+
const check = setInterval(() => {
|
|
335
|
+
if (buildUrl) {
|
|
336
|
+
clearTimeout(timeout);
|
|
337
|
+
clearInterval(check);
|
|
338
|
+
resolve();
|
|
339
|
+
}
|
|
340
|
+
}, 500);
|
|
341
|
+
});
|
|
342
|
+
child.unref();
|
|
343
|
+
setTimeout(async () => {
|
|
344
|
+
try {
|
|
345
|
+
await unlink(configPath);
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
/* ignore */
|
|
349
|
+
}
|
|
350
|
+
}, 120000);
|
|
351
|
+
output += `**Replaying ${uniqueNames.size} snapshots...**\n\n`;
|
|
352
|
+
if (buildUrl) {
|
|
353
|
+
output += `**Build URL:** ${buildUrl}\n\n`;
|
|
354
|
+
output += `Percy CLI is re-snapshotting with full resource discovery.\n`;
|
|
355
|
+
output += `Results ready in 1-3 minutes.\n`;
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
const percyLines = stdoutData
|
|
359
|
+
.split("\n")
|
|
360
|
+
.filter((l) => l.includes("[percy"))
|
|
361
|
+
.slice(0, 10);
|
|
362
|
+
if (percyLines.length > 0) {
|
|
363
|
+
output += `**Percy output:**\n\`\`\`\n${percyLines.join("\n")}\n\`\`\`\n`;
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
output += `Percy is processing in background. Check dashboard.\n`;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return { content: [{ type: "text", text: output }] };
|
|
370
|
+
}
|
|
371
|
+
// ── Screenshot Clone (direct API with tiles) ────────────────────────────────
|
|
372
|
+
//
|
|
373
|
+
// Full parity clone: same snapshot names, all widths, all browsers, all devices.
|
|
374
|
+
//
|
|
375
|
+
// API flow per snapshot:
|
|
376
|
+
// 1. POST /builds/:id/snapshots → exact source name
|
|
377
|
+
// 2. For each comparison (width × browser):
|
|
378
|
+
// a. Download screenshot image
|
|
379
|
+
// b. POST /snapshots/:id/comparisons → tag (browser/device) + tile SHA
|
|
380
|
+
// c. POST /comparisons/:id/tiles → upload image (strict base64)
|
|
381
|
+
// d. POST /comparisons/:id/finalize → finalize comparison
|
|
382
|
+
// 3. POST /builds/:id/finalize → finalize build
|
|
383
|
+
//
|
|
384
|
+
async function cloneViaApi(output, snapshots, token, branch, projectName, totalComps) {
|
|
385
|
+
output += `### Mode: Screenshot Clone (full parity)\n\n`;
|
|
386
|
+
output += `**Project:** ${projectName}\n`;
|
|
387
|
+
output += `Cloning ${snapshots.length} snapshots, ${totalComps} comparisons.\n\n`;
|
|
388
|
+
const commitSha = createHash("sha1")
|
|
389
|
+
.update(Date.now().toString())
|
|
390
|
+
.digest("hex");
|
|
391
|
+
// Step 1: Create build
|
|
392
|
+
let buildResult;
|
|
393
|
+
try {
|
|
394
|
+
buildResult = await percyTokenPost("/builds", token, {
|
|
395
|
+
data: {
|
|
396
|
+
type: "builds",
|
|
397
|
+
attributes: { branch, "commit-sha": commitSha },
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
catch (e) {
|
|
402
|
+
output += `Failed to create build: ${e.message}\n`;
|
|
403
|
+
return { content: [{ type: "text", text: output }], isError: true };
|
|
404
|
+
}
|
|
405
|
+
const buildId = buildResult?.data?.id;
|
|
406
|
+
const buildUrl = buildResult?.data?.attributes?.["web-url"] || "";
|
|
407
|
+
output += `Build: **#${buildId}**`;
|
|
408
|
+
if (buildUrl)
|
|
409
|
+
output += ` — ${buildUrl}`;
|
|
410
|
+
output += "\n\n";
|
|
411
|
+
let clonedSnaps = 0;
|
|
412
|
+
let clonedComps = 0;
|
|
413
|
+
let failedComps = 0;
|
|
414
|
+
for (const snap of snapshots) {
|
|
415
|
+
const compsWithImages = snap.comparisons.filter((c) => c.imageUrl);
|
|
416
|
+
if (compsWithImages.length === 0) {
|
|
417
|
+
output += `- ${snap.name} — no screenshots, skipped\n`;
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
try {
|
|
421
|
+
// Step 2: Create snapshot with EXACT source name
|
|
422
|
+
const snapResult = await percyTokenPost(`/builds/${buildId}/snapshots`, token, {
|
|
423
|
+
data: {
|
|
424
|
+
type: "snapshots",
|
|
425
|
+
attributes: { name: snap.name },
|
|
426
|
+
},
|
|
427
|
+
});
|
|
428
|
+
const newSnapId = snapResult?.data?.id;
|
|
429
|
+
if (!newSnapId) {
|
|
430
|
+
output += `- ${snap.name} — snapshot creation failed\n`;
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
let snapCompCount = 0;
|
|
434
|
+
// Step 3: Create comparison for EACH width × browser combo
|
|
435
|
+
for (const comp of compsWithImages) {
|
|
436
|
+
try {
|
|
437
|
+
// Download screenshot
|
|
438
|
+
const imgResponse = await fetch(comp.imageUrl);
|
|
439
|
+
if (!imgResponse.ok) {
|
|
440
|
+
failedComps++;
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
const imgBuffer = Buffer.from(await imgResponse.arrayBuffer());
|
|
444
|
+
const sha = createHash("sha256").update(imgBuffer).digest("hex");
|
|
445
|
+
const base64 = toStrictBase64(imgBuffer);
|
|
446
|
+
// Create comparison: attributes (required) + relationships (tag + tiles)
|
|
447
|
+
const compResult = await percyTokenPost(`/snapshots/${newSnapId}/comparisons`, token, {
|
|
448
|
+
data: {
|
|
449
|
+
type: "comparisons",
|
|
450
|
+
attributes: {
|
|
451
|
+
"external-debug-url": null,
|
|
452
|
+
"dom-info-sha": null,
|
|
453
|
+
},
|
|
454
|
+
relationships: {
|
|
455
|
+
tag: {
|
|
456
|
+
data: {
|
|
457
|
+
type: "tag",
|
|
458
|
+
attributes: {
|
|
459
|
+
name: comp.tagName,
|
|
460
|
+
width: comp.width,
|
|
461
|
+
height: comp.height,
|
|
462
|
+
"os-name": comp.osName || "",
|
|
463
|
+
"os-version": comp.osVersion || "",
|
|
464
|
+
"browser-name": comp.browserName || "",
|
|
465
|
+
"browser-version": comp.browserVersion || "",
|
|
466
|
+
orientation: comp.orientation || "portrait",
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
tiles: {
|
|
471
|
+
data: [
|
|
472
|
+
{
|
|
473
|
+
type: "tiles",
|
|
474
|
+
attributes: {
|
|
475
|
+
sha,
|
|
476
|
+
"status-bar-height": 0,
|
|
477
|
+
"nav-bar-height": 0,
|
|
478
|
+
"header-height": 0,
|
|
479
|
+
"footer-height": 0,
|
|
480
|
+
fullscreen: false,
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
],
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
});
|
|
488
|
+
const compId = compResult?.data?.id;
|
|
489
|
+
if (compId) {
|
|
490
|
+
// Upload tile image
|
|
491
|
+
await percyTokenPost(`/comparisons/${compId}/tiles`, token, {
|
|
492
|
+
data: {
|
|
493
|
+
type: "tiles",
|
|
494
|
+
attributes: { "base64-content": base64 },
|
|
495
|
+
},
|
|
496
|
+
});
|
|
497
|
+
// Finalize comparison
|
|
498
|
+
await percyTokenPost(`/comparisons/${compId}/finalize`, token, {});
|
|
499
|
+
snapCompCount++;
|
|
500
|
+
clonedComps++;
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
failedComps++;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
catch (compErr) {
|
|
507
|
+
failedComps++;
|
|
508
|
+
const msg = compErr.message?.slice(0, 100) || "unknown error";
|
|
509
|
+
output += ` ! ${comp.browserName} ${comp.width}px: ${msg}\n`;
|
|
510
|
+
}
|
|
511
|
+
// Rate limit protection — 500ms between comparisons (3 API calls each)
|
|
512
|
+
await delay(500);
|
|
513
|
+
}
|
|
514
|
+
clonedSnaps++;
|
|
515
|
+
output += `- **${snap.name}** — ${snapCompCount}/${compsWithImages.length} comparisons\n`;
|
|
516
|
+
}
|
|
517
|
+
catch (e) {
|
|
518
|
+
output += `- FAILED ${snap.name}: ${e.message}\n`;
|
|
519
|
+
}
|
|
520
|
+
// Delay between snapshots to avoid Cloudflare rate limits
|
|
521
|
+
await delay(1000);
|
|
522
|
+
}
|
|
523
|
+
// Finalize build
|
|
524
|
+
try {
|
|
525
|
+
await percyTokenPost(`/builds/${buildId}/finalize`, token, {});
|
|
526
|
+
}
|
|
527
|
+
catch (e) {
|
|
528
|
+
output += `\nFinalize failed: ${e.message}\n`;
|
|
529
|
+
}
|
|
530
|
+
// Summary
|
|
531
|
+
output += `\n---\n`;
|
|
532
|
+
output += `**Result:** ${clonedSnaps}/${snapshots.length} snapshots, ${clonedComps}/${totalComps} comparisons cloned`;
|
|
533
|
+
if (failedComps > 0)
|
|
534
|
+
output += ` (${failedComps} failed)`;
|
|
535
|
+
output += `\n`;
|
|
536
|
+
if (buildUrl)
|
|
537
|
+
output += `**View:** ${buildUrl}\n`;
|
|
538
|
+
return { content: [{ type: "text", text: output }] };
|
|
539
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* percy_create_app_build — Create an App Percy BYOS (Bring Your Own Screenshots) build.
|
|
3
|
+
*
|
|
4
|
+
* Two modes:
|
|
5
|
+
* 1. Sample mode (use_sample_data=true): auto-generates 3 devices × 2 screenshots
|
|
6
|
+
* using sharp. Zero setup — just provide a project name.
|
|
7
|
+
* 2. Custom mode (resources_dir): reads your own device folders with device.json + PNGs.
|
|
8
|
+
*
|
|
9
|
+
* Expected directory structure for custom mode:
|
|
10
|
+
* resources/
|
|
11
|
+
* iPhone_14_Pro/
|
|
12
|
+
* device.json ← { deviceName, osName, osVersion, orientation, deviceScreenSize }
|
|
13
|
+
* Home.png
|
|
14
|
+
* Settings.png
|
|
15
|
+
* Pixel_7/
|
|
16
|
+
* device.json
|
|
17
|
+
* Home.png
|
|
18
|
+
*/
|
|
19
|
+
import { BrowserStackConfig } from "../../../lib/types.js";
|
|
20
|
+
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
21
|
+
export interface CreateAppBuildArgs {
|
|
22
|
+
project_name: string;
|
|
23
|
+
resources_dir?: string;
|
|
24
|
+
use_sample_data?: boolean;
|
|
25
|
+
branch?: string;
|
|
26
|
+
test_case?: string;
|
|
27
|
+
}
|
|
28
|
+
export declare function percyCreateAppBuildV2(args: CreateAppBuildArgs, config: BrowserStackConfig): Promise<CallToolResult>;
|