();
+
+ for (const result of results) {
+ const existing = grouped.get(result.status) || [];
+ existing.push(result);
+ grouped.set(result.status, existing);
+ }
+
+ return grouped;
+}
+
+/**
+ * Generates a simple line-by-line diff HTML
+ */
+function generateDiffHtml(fileContent: IFileContentComparison): string {
+ if (fileContent.isBinary) {
+ return `${escapeHtml(Constants.Strings.HTML_REPORT_BINARY_FILE_MESSAGE)}
`;
+ }
+
+ const { localContent, remoteContent, result } = fileContent;
+
+ // Handle cases where content couldn't be read
+ if (localContent === null && remoteContent === null) {
+ return `${escapeHtml(Constants.Strings.HTML_REPORT_UNABLE_TO_READ_CONTENTS)}
`;
+ }
+
+ // For added files, show all lines as additions
+ if (result.status === FileComparisonStatus.ADDED) {
+ if (localContent === null) {
+ return `${escapeHtml(Constants.Strings.HTML_REPORT_UNABLE_TO_READ_LOCAL)}
`;
+ }
+ const lines = localContent.split("\n");
+ const diffLines = lines.map((line, idx) =>
+ `${idx + 1}+ ${escapeHtml(line)}
`
+ ).join("");
+ return `${diffLines}
`;
+ }
+
+ // For deleted files, show all lines as deletions
+ if (result.status === FileComparisonStatus.DELETED) {
+ if (remoteContent === null) {
+ return `${escapeHtml(Constants.Strings.HTML_REPORT_UNABLE_TO_READ_REMOTE)}
`;
+ }
+ const lines = remoteContent.split("\n");
+ const diffLines = lines.map((line, idx) =>
+ `${idx + 1}- ${escapeHtml(line)}
`
+ ).join("");
+ return `${diffLines}
`;
+ }
+
+ // For modified files, compute a simple diff
+ if (localContent === null || remoteContent === null) {
+ return `${escapeHtml(Constants.Strings.HTML_REPORT_UNABLE_TO_READ_BOTH)}
`;
+ }
+
+ const diffLines = computeSimpleDiff(remoteContent, localContent);
+ return `${diffLines}
`;
+}
+
+/**
+ * Computes a simple line-by-line diff between two texts
+ * Uses a basic longest common subsequence approach
+ */
+function computeSimpleDiff(oldText: string, newText: string): string {
+ const oldLines = oldText.split("\n");
+ const newLines = newText.split("\n");
+
+ // Simple diff using LCS
+ const lcs = computeLCS(oldLines, newLines);
+ const result: string[] = [];
+
+ let oldIdx = 0;
+ let newIdx = 0;
+ let oldLineNum = 1;
+ let newLineNum = 1;
+
+ for (const commonLine of lcs) {
+ // Output deletions (lines in old but not in common)
+ while (oldIdx < oldLines.length && oldLines[oldIdx] !== commonLine) {
+ result.push(`${oldLineNum}- ${escapeHtml(oldLines[oldIdx])}
`);
+ oldIdx++;
+ oldLineNum++;
+ }
+
+ // Output additions (lines in new but not in common)
+ while (newIdx < newLines.length && newLines[newIdx] !== commonLine) {
+ result.push(`${newLineNum}+ ${escapeHtml(newLines[newIdx])}
`);
+ newIdx++;
+ newLineNum++;
+ }
+
+ // Output the common line
+ result.push(`${oldLineNum}${newLineNum} ${escapeHtml(commonLine)}
`);
+ oldIdx++;
+ newIdx++;
+ oldLineNum++;
+ newLineNum++;
+ }
+
+ // Output remaining deletions
+ while (oldIdx < oldLines.length) {
+ result.push(`${oldLineNum}- ${escapeHtml(oldLines[oldIdx])}
`);
+ oldIdx++;
+ oldLineNum++;
+ }
+
+ // Output remaining additions
+ while (newIdx < newLines.length) {
+ result.push(`${newLineNum}+ ${escapeHtml(newLines[newIdx])}
`);
+ newIdx++;
+ newLineNum++;
+ }
+
+ return result.join("");
+}
+
+/**
+ * Computes the longest common subsequence of two string arrays
+ */
+function computeLCS(arr1: string[], arr2: string[]): string[] {
+ const m = arr1.length;
+ const n = arr2.length;
+
+ // Create DP table
+ const dp: number[][] = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0));
+
+ for (let i = 1; i <= m; i++) {
+ for (let j = 1; j <= n; j++) {
+ if (arr1[i - 1] === arr2[j - 1]) {
+ dp[i][j] = dp[i - 1][j - 1] + 1;
+ } else {
+ dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
+ }
+ }
+ }
+
+ // Backtrack to find LCS
+ const lcs: string[] = [];
+ let i = m;
+ let j = n;
+
+ while (i > 0 && j > 0) {
+ if (arr1[i - 1] === arr2[j - 1]) {
+ lcs.unshift(arr1[i - 1]);
+ i--;
+ j--;
+ } else if (dp[i - 1][j] > dp[i][j - 1]) {
+ i--;
+ } else {
+ j--;
+ }
+ }
+
+ return lcs;
+}
+
+/**
+ * Generates the HTML content for the report
+ */
+function generateHtmlContent(
+ fileContents: IFileContentComparison[],
+ siteName: string,
+ environmentName: string
+): string {
+ const generatedDate = formatDateForDisplay(new Date());
+ const comparisonResults = fileContents.map(fc => fc.result);
+ const groupedResults = groupByStatus(comparisonResults);
+
+ const addedCount = groupedResults.get(FileComparisonStatus.ADDED)?.length || 0;
+ const modifiedCount = groupedResults.get(FileComparisonStatus.MODIFIED)?.length || 0;
+ const deletedCount = groupedResults.get(FileComparisonStatus.DELETED)?.length || 0;
+
+ // Sort results by path
+ const sortedFileContents = [...fileContents].sort((a, b) =>
+ a.result.relativePath.localeCompare(b.result.relativePath)
+ );
+
+ const fileRows = sortedFileContents.map((fc, index) => {
+ const result = fc.result;
+ const diffHtml = generateDiffHtml(fc);
+
+ return `
+ `;
+ }).join("");
+
+ return `
+
+
+
+
+ ${escapeHtml(Constants.Strings.HTML_REPORT_TITLE)} - ${escapeHtml(siteName)}
+
+
+
+
+
+
+
+
+
+
${comparisonResults.length}
+
${escapeHtml(Constants.Strings.HTML_REPORT_TOTAL_CHANGES)}
+
+
+
${addedCount}
+
${escapeHtml(Constants.Strings.HTML_REPORT_ADDED)}
+
+
+
${modifiedCount}
+
${escapeHtml(Constants.Strings.HTML_REPORT_MODIFIED)}
+
+
+
${deletedCount}
+
${escapeHtml(Constants.Strings.HTML_REPORT_DELETED)}
+
+
+
+
+ ${escapeHtml(Constants.Strings.HTML_REPORT_COMPARISON_DETAILS)}
+
+
+ ${escapeHtml(Constants.Strings.HTML_REPORT_SITE_NAME_LABEL)}
+ ${escapeHtml(siteName)}
+
+
+ ${escapeHtml(Constants.Strings.HTML_REPORT_ENVIRONMENT_LABEL)}
+ ${escapeHtml(environmentName)}
+
+
+ ${escapeHtml(Constants.Strings.HTML_REPORT_GENERATED_LABEL)}
+ ${escapeHtml(generatedDate)}
+
+
+
+
+
+
+
+
+
+`;
+}
+
+/**
+ * Escapes HTML special characters to prevent XSS
+ */
+function escapeHtml(unsafe: string): string {
+ return unsafe
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+/**
+ * Shows the HTML report in a VS Code webview panel
+ */
+function showHtmlReportInWebview(htmlContent: string, siteName: string): void {
+ const panel = vscode.window.createWebviewPanel(
+ "metadataDiffReport",
+ `${Constants.Strings.HTML_REPORT_TITLE} - ${siteName}`,
+ vscode.ViewColumn.One,
+ {
+ enableScripts: true,
+ retainContextWhenHidden: false
+ }
+ );
+
+ panel.webview.html = htmlContent;
+}
diff --git a/src/client/power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler.ts b/src/client/power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler.ts
new file mode 100644
index 000000000..63b1360f3
--- /dev/null
+++ b/src/client/power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler.ts
@@ -0,0 +1,270 @@
+/*
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ */
+
+import * as vscode from "vscode";
+import * as fs from "fs";
+import path from "path";
+import { Constants } from "../../Constants";
+import { traceError, traceInfo } from "../../TelemetryHelper";
+import { IMetadataDiffExport, METADATA_DIFF_EXPORT_VERSION } from "../../models/IMetadataDiffExport";
+import { FileComparisonStatus, IFileComparisonResult } from "../../models/IFileComparisonResult";
+import MetadataDiffContext from "../../MetadataDiffContext";
+import { getExtensionVersion } from "../../../../../common/utilities/Utils";
+
+/**
+ * Compares two semantic version strings
+ * @param v1 First version string (e.g., "1.2.3")
+ * @param v2 Second version string (e.g., "1.2.4")
+ * @returns -1 if v1 < v2, 0 if v1 === v2, 1 if v1 > v2
+ */
+function compareVersions(v1: string, v2: string): number {
+ const parts1 = v1.split(".").map(p => parseInt(p, 10) || 0);
+ const parts2 = v2.split(".").map(p => parseInt(p, 10) || 0);
+
+ const maxLength = Math.max(parts1.length, parts2.length);
+
+ for (let i = 0; i < maxLength; i++) {
+ const num1 = parts1[i] || 0;
+ const num2 = parts2[i] || 0;
+
+ if (num1 < num2) return -1;
+ if (num1 > num2) return 1;
+ }
+
+ return 0;
+}
+
+/**
+ * Validates the import data structure
+ * @param data The parsed JSON data
+ * @returns Error message if invalid, undefined if valid
+ */
+function validateImportData(data: unknown): string | undefined {
+ if (!data || typeof data !== "object") {
+ return Constants.Strings.METADATA_DIFF_EXPORT_INVALID_FILE;
+ }
+
+ const importData = data as Partial;
+
+ // Check version (strict equality)
+ if (importData.version !== METADATA_DIFF_EXPORT_VERSION) {
+ return Constants.Strings.METADATA_DIFF_EXPORT_UNSUPPORTED_VERSION;
+ }
+
+ // Check required fields
+ const requiredFields: (keyof IMetadataDiffExport)[] = [
+ "version",
+ "extensionVersion",
+ "exportedAt",
+ "websiteId",
+ "websiteName",
+ "environmentId",
+ "environmentName",
+ "localSiteName",
+ "files"
+ ];
+
+ for (const field of requiredFields) {
+ if (importData[field] === undefined || importData[field] === null) {
+ return Constants.StringFunctions.METADATA_DIFF_MISSING_REQUIRED_FIELD(field);
+ }
+ }
+
+ // Check that extension version is not newer than current
+ const currentVersion = getExtensionVersion();
+ if (importData.extensionVersion && currentVersion) {
+ if (compareVersions(importData.extensionVersion, currentVersion) > 0) {
+ return Constants.Strings.METADATA_DIFF_EXPORT_NEWER_EXTENSION_VERSION;
+ }
+ }
+
+ // Validate files array
+ if (!Array.isArray(importData.files)) {
+ return Constants.StringFunctions.METADATA_DIFF_MISSING_REQUIRED_FIELD("files");
+ }
+
+ // Validate each file entry
+ for (const file of importData.files) {
+ if (!file.relativePath || typeof file.relativePath !== "string") {
+ return Constants.StringFunctions.METADATA_DIFF_MISSING_REQUIRED_FIELD("files[].relativePath");
+ }
+ if (!file.status || !Object.values(FileComparisonStatus).includes(file.status)) {
+ return Constants.StringFunctions.METADATA_DIFF_MISSING_REQUIRED_FIELD("files[].status");
+ }
+ }
+
+ return undefined;
+}
+
+/**
+ * Imports a metadata diff from a JSON file
+ */
+export async function importMetadataDiff(): Promise {
+ traceInfo(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_IMPORT_CALLED, {
+ methodName: importMetadataDiff.name
+ });
+
+ try {
+ // Show open dialog first (before progress)
+ const openUris = await vscode.window.showOpenDialog({
+ canSelectMany: false,
+ filters: {
+ [Constants.Strings.METADATA_DIFF_EXPORT_FILTER_NAME]: ["json"]
+ },
+ title: vscode.l10n.t("Import Metadata Diff")
+ });
+
+ if (!openUris || openUris.length === 0) {
+ return; // User cancelled
+ }
+
+ const fileUri = openUris[0];
+
+ // Read and parse the file first to validate before showing progress
+ let importData: IMetadataDiffExport;
+
+ try {
+ const fileContent = fs.readFileSync(fileUri.fsPath, "utf8");
+ const parsed = JSON.parse(fileContent);
+
+ // Validate the data
+ const validationError = validateImportData(parsed);
+ if (validationError) {
+ vscode.window.showErrorMessage(validationError);
+ traceError(
+ Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_IMPORT_FAILED,
+ new Error(validationError),
+ { methodName: importMetadataDiff.name, reason: "validation_failed" }
+ );
+ return;
+ }
+
+ importData = parsed as IMetadataDiffExport;
+ } catch (parseError) {
+ vscode.window.showErrorMessage(Constants.Strings.METADATA_DIFF_EXPORT_INVALID_FILE);
+ traceError(
+ Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_IMPORT_FAILED,
+ parseError as Error,
+ { methodName: importMetadataDiff.name, reason: "parse_failed" }
+ );
+ return;
+ }
+
+ // Check if there's already an imported comparison for this site
+ if (MetadataDiffContext.hasImportedComparison(importData.websiteId, importData.environmentId)) {
+ const confirmation = await vscode.window.showWarningMessage(
+ Constants.Strings.METADATA_DIFF_REPLACE_EXISTING_IMPORT,
+ { modal: true },
+ Constants.Strings.REPLACE
+ );
+
+ if (confirmation !== Constants.Strings.REPLACE) {
+ return;
+ }
+ }
+
+ // Get the storage path for imported diffs
+ const extensionContext = MetadataDiffContext.extensionContext;
+ if (!extensionContext?.globalStorageUri) {
+ traceError(
+ Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_IMPORT_FAILED,
+ new Error("Global storage URI not available"),
+ { methodName: importMetadataDiff.name, reason: "no_storage" }
+ );
+ return;
+ }
+
+ // Now show progress while doing the actual file writing work
+ await vscode.window.withProgress(
+ {
+ location: vscode.ProgressLocation.Notification,
+ title: Constants.Strings.METADATA_DIFF_IMPORT_PROGRESS,
+ cancellable: false
+ },
+ async () => {
+ const importedDiffsPath = path.join(
+ extensionContext.globalStorageUri.fsPath,
+ "imported-diffs",
+ `${importData.websiteId}_${importData.environmentId}`
+ );
+
+ // Create the directory if it doesn't exist
+ if (!fs.existsSync(importedDiffsPath)) {
+ fs.mkdirSync(importedDiffsPath, { recursive: true });
+ }
+
+ // Write the file contents to the storage
+ const comparisonResults: IFileComparisonResult[] = [];
+
+ for (const file of importData.files) {
+ const localPath = path.join(importedDiffsPath, "local", file.relativePath);
+ const remotePath = path.join(importedDiffsPath, "remote", file.relativePath);
+
+ // Ensure directories exist
+ const localDir = path.dirname(localPath);
+ const remoteDir = path.dirname(remotePath);
+
+ if (!fs.existsSync(localDir)) {
+ fs.mkdirSync(localDir, { recursive: true });
+ }
+ if (!fs.existsSync(remoteDir)) {
+ fs.mkdirSync(remoteDir, { recursive: true });
+ }
+
+ // Write local content if available
+ if (file.localContent) {
+ const content = Buffer.from(file.localContent, "base64");
+ fs.writeFileSync(localPath, content);
+ }
+
+ // Write remote content if available
+ if (file.remoteContent) {
+ const content = Buffer.from(file.remoteContent, "base64");
+ fs.writeFileSync(remotePath, content);
+ }
+
+ comparisonResults.push({
+ relativePath: file.relativePath,
+ status: file.status,
+ localPath,
+ remotePath
+ });
+ }
+
+ // Store the results in the context
+ MetadataDiffContext.setResults(
+ comparisonResults,
+ importData.websiteName,
+ importData.localSiteName,
+ importData.environmentName,
+ importData.websiteId,
+ importData.environmentId,
+ true, // isImported
+ importData.exportedAt
+ );
+
+ traceInfo(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_IMPORT_SUCCESS, {
+ methodName: importMetadataDiff.name,
+ websiteId: importData.websiteId,
+ fileCount: comparisonResults.length.toString()
+ });
+ }
+ );
+
+ // Show success message after progress completes
+ vscode.window.showInformationMessage(
+ Constants.StringFunctions.METADATA_DIFF_IMPORT_SUCCESS(importData.websiteName)
+ );
+ } catch (error) {
+ traceError(
+ Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_IMPORT_FAILED,
+ error as Error,
+ { methodName: importMetadataDiff.name }
+ );
+ vscode.window.showErrorMessage(
+ Constants.StringFunctions.METADATA_DIFF_IMPORT_FAILED((error as Error).message)
+ );
+ }
+}
diff --git a/src/client/power-pages/actions-hub/handlers/metadata-diff/MetadataDiffUtils.ts b/src/client/power-pages/actions-hub/handlers/metadata-diff/MetadataDiffUtils.ts
index 952f0ddd2..6f052291b 100644
--- a/src/client/power-pages/actions-hub/handlers/metadata-diff/MetadataDiffUtils.ts
+++ b/src/client/power-pages/actions-hub/handlers/metadata-diff/MetadataDiffUtils.ts
@@ -14,6 +14,7 @@ import { showProgressWithNotification } from "../../../../../common/utilities/Ut
import { FileComparisonStatus, IFileComparisonResult } from "../../models/IFileComparisonResult";
import { getAllFiles } from "../../ActionsHubUtils";
import MetadataDiffContext from "../../MetadataDiffContext";
+import PacContext from "../../../../pac/PacContext";
/**
* Result of resolving site information from workspace
@@ -21,6 +22,11 @@ import MetadataDiffContext from "../../MetadataDiffContext";
export interface SiteResolutionResult {
siteId: string;
localSitePath: string;
+ /**
+ * The relative path from site root to the folder user clicked on.
+ * Empty string means the entire site should be compared.
+ */
+ comparisonSubPath: string;
}
/**
@@ -87,19 +93,34 @@ export function compareFiles(downloadedSitePath: string, localSitePath: string):
export function resolveSiteFromWorkspace(workingDirectory: string, resource?: vscode.Uri): SiteResolutionResult | undefined {
let siteId: string | undefined;
let localSitePath = workingDirectory;
+ let comparisonSubPath = "";
- // Strategy 1: Check if website.yml exists directly in working directory
- siteId = getWebsiteRecordId(workingDirectory);
-
- // Strategy 2: If resource is provided, traverse up from resource to find website.yml
- if (!siteId && resource?.fsPath) {
+ // Strategy 1: If resource is provided, traverse up from resource to find website.yml
+ // This takes priority as it identifies the specific site the user clicked on
+ if (resource?.fsPath) {
const websiteYmlFolder = findWebsiteYmlFolder(resource.fsPath);
if (websiteYmlFolder) {
siteId = getWebsiteRecordId(websiteYmlFolder);
localSitePath = websiteYmlFolder;
+
+ // Calculate the relative path from site root to the resource
+ // This allows comparing only the specific folder the user clicked on
+ const resourcePath = resource.fsPath;
+ if (resourcePath.startsWith(websiteYmlFolder)) {
+ const relativePath = path.relative(websiteYmlFolder, resourcePath);
+ // Only set comparisonSubPath if the resource is different from the site root
+ if (relativePath && relativePath !== "." && !relativePath.startsWith("..")) {
+ comparisonSubPath = relativePath;
+ }
+ }
}
}
+ // Strategy 2: Check if website.yml exists directly in working directory
+ if (!siteId) {
+ siteId = getWebsiteRecordId(workingDirectory);
+ }
+
// Strategy 3: Look for a 'site' folder in working directory
if (!siteId) {
const powerPagesSiteFolder = findPowerPagesSiteFolder(workingDirectory);
@@ -117,7 +138,7 @@ export function resolveSiteFromWorkspace(workingDirectory: string, resource?: vs
return undefined;
}
- return { siteId, localSitePath };
+ return { siteId, localSitePath, comparisonSubPath };
}
/**
@@ -139,24 +160,33 @@ export function prepareSiteStoragePath(storagePath: string, websiteId: string):
* Processes comparison results and updates the MetadataDiffContext
* @param siteStoragePath Path where site was downloaded
* @param localSitePath Path to local site
- * @param siteName Name of the site being compared
+ * @param siteName Name of the remote site being compared
+ * @param localSiteName Name of the local site
* @param environmentName Name of the environment
* @param methodName Name of the calling method for telemetry
* @param siteId Site ID for telemetry
* @param completedEventName Telemetry event name for completion
* @param noDifferencesEventName Telemetry event name for no differences
+ * @param comparisonSubPath Optional sub-path to filter comparison results to a specific folder
+ * @param environmentId Optional environment ID (defaults to current environment if not provided)
+ * @param dataModelVersion Optional data model version (1 = Standard, 2 = Enhanced)
+ * @returns True if differences were found, false otherwise
*/
export async function processComparisonResults(
siteStoragePath: string,
localSitePath: string,
siteName: string,
+ localSiteName: string,
environmentName: string,
methodName: string,
siteId: string,
completedEventName: string,
- noDifferencesEventName: string
-): Promise {
- await showProgressWithNotification(
+ noDifferencesEventName: string,
+ comparisonSubPath?: string,
+ environmentId?: string,
+ dataModelVersion?: 1 | 2
+): Promise {
+ const comparisonResults = await showProgressWithNotification(
Constants.Strings.COMPARING_FILES,
async () => {
// Find the actual downloaded site folder (name is not deterministic)
@@ -165,30 +195,56 @@ export async function processComparisonResults(
.map(entry => entry.name);
const siteDownloadPath = path.join(siteStoragePath, downloadedFolders[0]);
- const comparisonResults = compareFiles(siteDownloadPath, localSitePath);
-
- if (comparisonResults.length === 0) {
- traceInfo(noDifferencesEventName, {
- methodName,
- siteId
- });
- await vscode.window.showInformationMessage(Constants.Strings.NO_DIFFERENCES_FOUND);
- MetadataDiffContext.clear();
- } else {
- traceInfo(completedEventName, {
- methodName,
- siteId,
- totalDifferences: comparisonResults.length.toString(),
- modifiedFiles: comparisonResults.filter(r => r.status === FileComparisonStatus.MODIFIED).length.toString(),
- addedFiles: comparisonResults.filter(r => r.status === FileComparisonStatus.ADDED).length.toString(),
- deletedFiles: comparisonResults.filter(r => r.status === FileComparisonStatus.DELETED).length.toString()
+ let results = compareFiles(siteDownloadPath, localSitePath);
+
+ // Filter results to only include files under the comparison sub-path
+ if (comparisonSubPath) {
+ const normalizedSubPath = comparisonSubPath.replace(/\\/g, "/");
+ results = results.filter(result => {
+ const normalizedRelativePath = result.relativePath.replace(/\\/g, "/");
+ return normalizedRelativePath.startsWith(normalizedSubPath + "/") ||
+ normalizedRelativePath === normalizedSubPath;
});
-
- // Store results in the context so the tree view can display them
- MetadataDiffContext.setResults(comparisonResults, siteName, environmentName);
}
- return true;
+ return results;
}
);
+
+ // Handle results after progress notification is dismissed
+ if (comparisonResults.length === 0) {
+ traceInfo(noDifferencesEventName, {
+ methodName,
+ siteId
+ });
+ // Don't await - show notification without blocking so callers can update UI immediately
+ vscode.window.showInformationMessage(Constants.Strings.NO_DIFFERENCES_FOUND);
+ return false;
+ } else {
+ traceInfo(completedEventName, {
+ methodName,
+ siteId,
+ totalDifferences: comparisonResults.length.toString(),
+ modifiedFiles: comparisonResults.filter(r => r.status === FileComparisonStatus.MODIFIED).length.toString(),
+ addedFiles: comparisonResults.filter(r => r.status === FileComparisonStatus.ADDED).length.toString(),
+ deletedFiles: comparisonResults.filter(r => r.status === FileComparisonStatus.DELETED).length.toString()
+ });
+
+ // Get the environment ID from context if not provided
+ const resolvedEnvironmentId = environmentId || PacContext.AuthInfo?.EnvironmentId || "";
+
+ // Store results in the context so the tree view can display them
+ MetadataDiffContext.setResults(
+ comparisonResults,
+ siteName,
+ localSiteName,
+ environmentName,
+ siteId,
+ resolvedEnvironmentId,
+ false, // isImported
+ undefined, // exportedAt
+ dataModelVersion
+ );
+ return true;
+ }
}
diff --git a/src/client/power-pages/actions-hub/handlers/metadata-diff/OpenAllMetadataDiffsHandler.ts b/src/client/power-pages/actions-hub/handlers/metadata-diff/OpenAllMetadataDiffsHandler.ts
index de8f35131..8cb658bd6 100644
--- a/src/client/power-pages/actions-hub/handlers/metadata-diff/OpenAllMetadataDiffsHandler.ts
+++ b/src/client/power-pages/actions-hub/handlers/metadata-diff/OpenAllMetadataDiffsHandler.ts
@@ -8,35 +8,7 @@ import { MetadataDiffSiteTreeItem } from "../../tree-items/metadata-diff/Metadat
import { traceInfo } from "../../TelemetryHelper";
import { Constants } from "../../Constants";
import { FileComparisonStatus, IFileComparisonResult } from "../../models/IFileComparisonResult";
-
-/**
- * Common binary file extensions that cannot be diffed in the text diff viewer
- */
-const BINARY_FILE_EXTENSIONS = new Set([
- // Images
- ".png", ".jpg", ".jpeg", ".gif", ".ico", ".webp", ".bmp", ".tiff", ".tif", ".svg",
- // Fonts
- ".woff", ".woff2", ".ttf", ".otf", ".eot",
- // Media
- ".mp4", ".mp3", ".wav", ".ogg", ".webm", ".avi", ".mov",
- // Documents
- ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
- // Archives
- ".zip", ".rar", ".7z", ".tar", ".gz",
- // Other binary
- ".exe", ".dll", ".so", ".dylib"
-]);
-
-/**
- * Checks if a file is a binary file based on its extension
- * @param relativePath The relative path of the file
- * @returns True if the file is binary, false otherwise
- */
-export function isBinaryFile(relativePath: string): boolean {
- const lowerPath = relativePath.toLowerCase();
- const extension = lowerPath.substring(lowerPath.lastIndexOf("."));
- return BINARY_FILE_EXTENSIONS.has(extension);
-}
+import { isBinaryFile } from "../../ActionsHubUtils";
/**
* Opens all file diffs in the multi-diff editor for a specific site
diff --git a/src/client/power-pages/actions-hub/handlers/metadata-diff/OpenMetadataDiffFileHandler.ts b/src/client/power-pages/actions-hub/handlers/metadata-diff/OpenMetadataDiffFileHandler.ts
index 3ceef113a..cbc6cea25 100644
--- a/src/client/power-pages/actions-hub/handlers/metadata-diff/OpenMetadataDiffFileHandler.ts
+++ b/src/client/power-pages/actions-hub/handlers/metadata-diff/OpenMetadataDiffFileHandler.ts
@@ -9,24 +9,31 @@ import { MetadataDiffFileTreeItem } from "../../tree-items/metadata-diff/Metadat
import { traceInfo } from "../../TelemetryHelper";
import { Constants } from "../../Constants";
import { FileComparisonStatus } from "../../models/IFileComparisonResult";
-import { isBinaryFile } from "./OpenAllMetadataDiffsHandler";
+import { isBinaryFile } from "../../ActionsHubUtils";
/**
* Opens a single file diff in the VS Code diff editor
*/
export async function openMetadataDiffFile(fileItem: MetadataDiffFileTreeItem): Promise {
- const { comparisonResult, siteName } = fileItem;
+ const { comparisonResult, siteName, isImported } = fileItem;
traceInfo(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_OPEN_FILE, {
methodName: openMetadataDiffFile.name,
relativePath: comparisonResult.relativePath,
- status: comparisonResult.status
+ status: comparisonResult.status,
+ isImported: isImported
});
const title = Constants.StringFunctions.COMPARE_FILE_TITLE(siteName, comparisonResult.relativePath);
// Handle binary files - open them directly instead of trying to diff
if (isBinaryFile(comparisonResult.relativePath)) {
+ // For imported comparisons, binary content is not available
+ if (isImported) {
+ vscode.window.showInformationMessage(Constants.Strings.METADATA_DIFF_BINARY_FILE_NOT_AVAILABLE);
+ return;
+ }
+
await openBinaryFile(comparisonResult.localPath, comparisonResult.remotePath, comparisonResult.status);
return;
}
diff --git a/src/client/power-pages/actions-hub/handlers/metadata-diff/ResyncMetadataDiffHandler.ts b/src/client/power-pages/actions-hub/handlers/metadata-diff/ResyncMetadataDiffHandler.ts
new file mode 100644
index 000000000..82c489e60
--- /dev/null
+++ b/src/client/power-pages/actions-hub/handlers/metadata-diff/ResyncMetadataDiffHandler.ts
@@ -0,0 +1,122 @@
+/*
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ */
+
+import * as vscode from "vscode";
+import { PacTerminal } from "../../../../lib/PacTerminal";
+import { Constants } from "../../Constants";
+import { traceError, traceInfo } from "../../TelemetryHelper";
+import { showProgressWithNotification } from "../../../../../common/utilities/Utils";
+import { MetadataDiffSiteTreeItem } from "../../tree-items/metadata-diff/MetadataDiffSiteTreeItem";
+import { resolveSiteFromWorkspace, prepareSiteStoragePath, processComparisonResults } from "./MetadataDiffUtils";
+import MetadataDiffContext from "../../MetadataDiffContext";
+
+/**
+ * Re-syncs (refreshes) the comparison results for a specific site by re-downloading
+ * the site metadata from the environment and re-running the comparison.
+ * This is useful when the remote site has been updated since the last comparison.
+ */
+export const resyncMetadataDiff = (pacTerminal: PacTerminal, context: vscode.ExtensionContext) => async (siteItem: MetadataDiffSiteTreeItem): Promise => {
+ traceInfo(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_RESYNC_CALLED, {
+ methodName: resyncMetadataDiff.name,
+ siteName: siteItem.siteName,
+ environmentName: siteItem.environmentName,
+ websiteId: siteItem.websiteId,
+ environmentId: siteItem.environmentId,
+ isImported: siteItem.isImported
+ });
+
+ // Cannot resync imported comparisons - they are static snapshots
+ if (siteItem.isImported) {
+ vscode.window.showWarningMessage(Constants.Strings.METADATA_DIFF_CANNOT_RESYNC_IMPORTED);
+ return;
+ }
+
+ const workspaceFolders = vscode.workspace.workspaceFolders;
+
+ if (!workspaceFolders || workspaceFolders.length === 0) {
+ traceInfo(Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_LOCAL_NO_WORKSPACE, {
+ methodName: resyncMetadataDiff.name
+ });
+ await vscode.window.showErrorMessage(Constants.Strings.NO_WORKSPACE_FOLDER_OPEN);
+ return;
+ }
+
+ const siteResolution = resolveSiteFromWorkspace(workspaceFolders[0].uri.fsPath);
+
+ if (!siteResolution) {
+ traceInfo(Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_LOCAL_WEBSITE_ID_NOT_FOUND, {
+ methodName: resyncMetadataDiff.name
+ });
+ await vscode.window.showErrorMessage(Constants.Strings.WEBSITE_ID_NOT_FOUND);
+ return;
+ }
+
+ const storagePath = context.storageUri?.fsPath;
+
+ if (!storagePath) {
+ return;
+ }
+
+ const siteStoragePath = prepareSiteStoragePath(storagePath, siteItem.websiteId);
+ const pacWrapper = pacTerminal.getWrapper();
+
+ // Use the data model version from the existing comparison results
+ // Default to version 1 if not available (e.g., for older comparisons)
+ const dataModelVersion: 1 | 2 = siteItem.dataModelVersion ?? 1;
+
+ const downloadStartTime = Date.now();
+ const success = await showProgressWithNotification(
+ Constants.StringFunctions.RESYNCING_SITE_COMPARISON(siteItem.siteName),
+ async () => pacWrapper.downloadSiteWithProgress(
+ siteStoragePath,
+ siteItem.websiteId,
+ dataModelVersion
+ )
+ );
+ const downloadDurationMs = Date.now() - downloadStartTime;
+
+ if (!success) {
+ traceError(
+ Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_RESYNC_FAILED,
+ new Error("MetadataDiff: Action 'resync' failed to download site."),
+ {
+ methodName: resyncMetadataDiff.name,
+ websiteId: siteItem.websiteId,
+ environmentId: siteItem.environmentId
+ }
+ );
+ await vscode.window.showErrorMessage(Constants.Strings.COMPARE_WITH_LOCAL_SITE_DOWNLOAD_FAILED);
+ return;
+ }
+
+ traceInfo(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_SITE_DOWNLOAD_COMPLETED, {
+ methodName: resyncMetadataDiff.name,
+ siteId: siteItem.websiteId,
+ environmentId: siteItem.environmentId,
+ downloadDurationMs: downloadDurationMs
+ });
+
+ const hasDifferences = await processComparisonResults(
+ siteStoragePath,
+ siteResolution.localSitePath,
+ siteItem.siteName,
+ siteItem.localSiteName,
+ siteItem.environmentName,
+ resyncMetadataDiff.name,
+ siteItem.websiteId,
+ Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_RESYNC_COMPLETED,
+ Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_RESYNC_NO_DIFFERENCES,
+ undefined, // No sub-path filtering for resync
+ siteItem.environmentId,
+ dataModelVersion
+ );
+
+ if (hasDifferences) {
+ await vscode.window.showInformationMessage(Constants.Strings.METADATA_DIFF_RESYNC_COMPLETED);
+ } else {
+ // Remove the comparison node since there are no more differences
+ MetadataDiffContext.clearSiteByKey(siteItem.websiteId, siteItem.environmentId, siteItem.isImported);
+ }
+};
diff --git a/src/client/power-pages/actions-hub/models/IFileComparisonResult.ts b/src/client/power-pages/actions-hub/models/IFileComparisonResult.ts
index a85edce04..641007747 100644
--- a/src/client/power-pages/actions-hub/models/IFileComparisonResult.ts
+++ b/src/client/power-pages/actions-hub/models/IFileComparisonResult.ts
@@ -20,3 +20,21 @@ export interface IFileComparisonResult {
relativePath: string;
status: FileComparisonStatusType;
}
+
+/**
+ * Interface for storing comparison results per site
+ */
+export interface ISiteComparisonResults {
+ siteName: string;
+ localSiteName: string;
+ environmentName: string;
+ websiteId: string;
+ environmentId: string;
+ comparisonResults: IFileComparisonResult[];
+ /** Whether this comparison was imported from an export file */
+ isImported?: boolean;
+ /** ISO 8601 timestamp when the comparison was exported (only set for imported comparisons) */
+ exportedAt?: string;
+ /** The data model version of the site (1 = Standard, 2 = Enhanced) */
+ dataModelVersion?: 1 | 2;
+}
diff --git a/src/client/power-pages/actions-hub/models/IMetadataDiffExport.ts b/src/client/power-pages/actions-hub/models/IMetadataDiffExport.ts
new file mode 100644
index 000000000..d22b735c3
--- /dev/null
+++ b/src/client/power-pages/actions-hub/models/IMetadataDiffExport.ts
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ */
+
+import { FileComparisonStatusType } from "./IFileComparisonResult";
+
+/**
+ * Export format version for metadata diff exports
+ */
+export const METADATA_DIFF_EXPORT_VERSION = "1.0";
+
+/**
+ * Represents the exported metadata diff data structure
+ */
+export interface IMetadataDiffExport {
+ /** Version of the export format */
+ version: string;
+ /** Version of the VS Code extension used to generate this export */
+ extensionVersion: string;
+ /** ISO 8601 timestamp when the export was created */
+ exportedAt: string;
+ /** ID of the website being compared */
+ websiteId: string;
+ /** Name of the website being compared */
+ websiteName: string;
+ /** ID of the environment */
+ environmentId: string;
+ /** Name of the environment */
+ environmentName: string;
+ /** Name of the local site */
+ localSiteName: string;
+ /** Array of file comparison results with content */
+ files: IExportableFileComparisonResult[];
+}
+
+/**
+ * Represents a file comparison result with content for export
+ */
+export interface IExportableFileComparisonResult {
+ /** Relative path of the file within the site */
+ relativePath: string;
+ /** Status of the file comparison (modified, added, deleted) */
+ status: FileComparisonStatusType;
+ /** Base64-encoded content of the local file, null for binary files or deleted files */
+ localContent: string | null;
+ /** Base64-encoded content of the remote file, null for binary files or added files */
+ remoteContent: string | null;
+}
diff --git a/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFileTreeItem.ts b/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFileTreeItem.ts
index c97fd80a1..796210fc0 100644
--- a/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFileTreeItem.ts
+++ b/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFileTreeItem.ts
@@ -21,10 +21,12 @@ export const METADATA_DIFF_URI_SCHEME = "pp-metadata-diff";
export class MetadataDiffFileTreeItem extends ActionsHubTreeItem {
public readonly comparisonResult: IFileComparisonResult;
private readonly _siteName: string;
+ private readonly _isImported: boolean;
constructor(
comparisonResult: IFileComparisonResult,
- siteName: string
+ siteName: string,
+ isImported: boolean = false
) {
// Use file name as the label
const fileName = comparisonResult.relativePath.split(/[/\\]/).pop() || comparisonResult.relativePath;
@@ -42,6 +44,7 @@ export class MetadataDiffFileTreeItem extends ActionsHubTreeItem {
);
this.comparisonResult = comparisonResult;
this._siteName = siteName;
+ this._isImported = isImported;
// Use resourceUri for file-type icon detection and decoration
// Encode the status in the URI so FileDecorationProvider can read it
@@ -99,4 +102,8 @@ export class MetadataDiffFileTreeItem extends ActionsHubTreeItem {
public get siteName(): string {
return this._siteName;
}
+
+ public get isImported(): boolean {
+ return this._isImported;
+ }
}
diff --git a/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFolderTreeItem.ts b/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFolderTreeItem.ts
index 3661d1d6d..419e213b6 100644
--- a/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFolderTreeItem.ts
+++ b/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFolderTreeItem.ts
@@ -29,7 +29,28 @@ export class MetadataDiffFolderTreeItem extends ActionsHubTreeItem {
}
public getChildren(): ActionsHubTreeItem[] {
- return Array.from(this.childrenMap.values());
+ const children = Array.from(this.childrenMap.values());
+
+ // Separate folders and files
+ const folders: MetadataDiffFolderTreeItem[] = [];
+ const files: MetadataDiffFileTreeItem[] = [];
+
+ for (const child of children) {
+ if (child instanceof MetadataDiffFolderTreeItem) {
+ folders.push(child);
+ } else {
+ files.push(child);
+ }
+ }
+
+ // Sort folders alphabetically by label
+ folders.sort((a, b) => (a.label as string).localeCompare(b.label as string));
+
+ // Sort files alphabetically by label
+ files.sort((a, b) => (a.label as string).localeCompare(b.label as string));
+
+ // Return folders first, then files
+ return [...folders, ...files];
}
public get siteName(): string {
diff --git a/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffGroupTreeItem.ts b/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffGroupTreeItem.ts
index 387d85022..4a1c228b7 100644
--- a/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffGroupTreeItem.ts
+++ b/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffGroupTreeItem.ts
@@ -38,8 +38,6 @@ export class MetadataDiffGroupTreeItem extends ActionsHubTreeItem {
}
// Create a site tree item for each site's comparison results
- return siteResults.map(siteResult =>
- new MetadataDiffSiteTreeItem(siteResult.comparisonResults, siteResult.siteName, siteResult.environmentName)
- );
+ return siteResults.map(siteResult => new MetadataDiffSiteTreeItem(siteResult));
}
}
diff --git a/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffSiteTreeItem.ts b/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffSiteTreeItem.ts
index 0f57b9961..2a0ad3526 100644
--- a/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffSiteTreeItem.ts
+++ b/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffSiteTreeItem.ts
@@ -6,7 +6,7 @@
import * as vscode from "vscode";
import { ActionsHubTreeItem } from "../ActionsHubTreeItem";
import { Constants } from "../../Constants";
-import { IFileComparisonResult, FileComparisonStatus } from "../../models/IFileComparisonResult";
+import { IFileComparisonResult, FileComparisonStatus, ISiteComparisonResults } from "../../models/IFileComparisonResult";
import { MetadataDiffFileTreeItem } from "./MetadataDiffFileTreeItem";
import { MetadataDiffFolderTreeItem } from "./MetadataDiffFolderTreeItem";
import MetadataDiffContext, { MetadataDiffSortMode } from "../../MetadataDiffContext";
@@ -18,23 +18,43 @@ import MetadataDiffContext, { MetadataDiffSortMode } from "../../MetadataDiffCon
export class MetadataDiffSiteTreeItem extends ActionsHubTreeItem {
private readonly _comparisonResults: IFileComparisonResult[];
private readonly _siteName: string;
+ private readonly _localSiteName: string;
private readonly _environmentName: string;
+ private readonly _websiteId: string;
+ private readonly _environmentId: string;
+ private readonly _isImported: boolean;
+ private readonly _exportedAt?: string;
+ private readonly _dataModelVersion?: 1 | 2;
+
+ constructor(siteResults: ISiteComparisonResults) {
+ const fileCount = siteResults.comparisonResults.length;
+ const isImported = siteResults.isImported ?? false;
+
+ const fileLabel = Constants.StringFunctions.COMPARISON_LABEL(siteResults.siteName, siteResults.environmentName, siteResults.localSiteName);
+ const description = fileCount === 1
+ ? Constants.Strings.FILE_COUNT_DESCRIPTION_SINGULAR
+ : Constants.StringFunctions.FILE_COUNT_DESCRIPTION_PLURAL(fileCount);
+
+ // Use different icons and context values for imported vs live comparisons
+ const icon = isImported ? Constants.Icons.IMPORTED_SITE : Constants.Icons.SITE;
+ const contextValue = isImported ? Constants.ContextValues.METADATA_DIFF_SITE_IMPORTED : Constants.ContextValues.METADATA_DIFF_SITE;
- constructor(comparisonResults: IFileComparisonResult[], siteName: string, environmentName: string) {
- const fileCount = comparisonResults.length;
- const fileLabel = fileCount === 1
- ? Constants.StringFunctions.SITE_WITH_FILE_COUNT_SINGULAR(siteName, fileCount)
- : Constants.StringFunctions.SITE_WITH_FILE_COUNT_PLURAL(siteName, fileCount);
super(
fileLabel,
vscode.TreeItemCollapsibleState.Expanded,
- Constants.Icons.SITE,
- Constants.ContextValues.METADATA_DIFF_SITE,
- environmentName // Show environment name as description/subtext
+ icon,
+ contextValue,
+ description
);
- this._comparisonResults = comparisonResults;
- this._siteName = siteName;
- this._environmentName = environmentName;
+ this._comparisonResults = siteResults.comparisonResults;
+ this._siteName = siteResults.siteName;
+ this._localSiteName = siteResults.localSiteName;
+ this._environmentName = siteResults.environmentName;
+ this._websiteId = siteResults.websiteId;
+ this._environmentId = siteResults.environmentId;
+ this._isImported = isImported;
+ this._exportedAt = siteResults.exportedAt;
+ this._dataModelVersion = siteResults.dataModelVersion;
}
public getChildren(): ActionsHubTreeItem[] {
@@ -54,7 +74,7 @@ export class MetadataDiffSiteTreeItem extends ActionsHubTreeItem {
// Create flat list of file items
return sortedResults.map(result =>
- new MetadataDiffFileTreeItem(result, this._siteName)
+ new MetadataDiffFileTreeItem(result, this._siteName, this._isImported)
);
}
@@ -136,7 +156,8 @@ export class MetadataDiffSiteTreeItem extends ActionsHubTreeItem {
// Add the file to the appropriate folder (or root if no folders)
const fileItem = new MetadataDiffFileTreeItem(
result,
- this._siteName
+ this._siteName,
+ this._isImported
);
if (currentFolder) {
@@ -146,7 +167,26 @@ export class MetadataDiffSiteTreeItem extends ActionsHubTreeItem {
}
}
- return Array.from(rootChildren.values());
+ // Separate folders and files at root level
+ const folders: MetadataDiffFolderTreeItem[] = [];
+ const files: MetadataDiffFileTreeItem[] = [];
+
+ for (const child of rootChildren.values()) {
+ if (child instanceof MetadataDiffFolderTreeItem) {
+ folders.push(child);
+ } else {
+ files.push(child);
+ }
+ }
+
+ // Sort folders alphabetically by label
+ folders.sort((a, b) => (a.label as string).localeCompare(b.label as string));
+
+ // Sort files alphabetically by label
+ files.sort((a, b) => (a.label as string).localeCompare(b.label as string));
+
+ // Return folders first, then files
+ return [...folders, ...files];
}
public get siteName(): string {
@@ -160,4 +200,28 @@ export class MetadataDiffSiteTreeItem extends ActionsHubTreeItem {
public get comparisonResults(): IFileComparisonResult[] {
return this._comparisonResults;
}
+
+ public get websiteId(): string {
+ return this._websiteId;
+ }
+
+ public get environmentId(): string {
+ return this._environmentId;
+ }
+
+ public get isImported(): boolean {
+ return this._isImported;
+ }
+
+ public get exportedAt(): string | undefined {
+ return this._exportedAt;
+ }
+
+ public get localSiteName(): string {
+ return this._localSiteName;
+ }
+
+ public get dataModelVersion(): (1 | 2) | undefined {
+ return this._dataModelVersion;
+ }
}
diff --git a/src/client/test/Integration/power-pages/actions-hub/ActionsHubTreeDataProvider.test.ts b/src/client/test/Integration/power-pages/actions-hub/ActionsHubTreeDataProvider.test.ts
index e6dc6eb05..d5bb4f87e 100644
--- a/src/client/test/Integration/power-pages/actions-hub/ActionsHubTreeDataProvider.test.ts
+++ b/src/client/test/Integration/power-pages/actions-hub/ActionsHubTreeDataProvider.test.ts
@@ -49,6 +49,10 @@ import * as ClearMetadataDiffHandler from "../../../../power-pages/actions-hub/h
import * as RemoveSiteHandler from "../../../../power-pages/actions-hub/handlers/metadata-diff/RemoveSiteHandler";
import * as DiscardLocalChangesHandler from "../../../../power-pages/actions-hub/handlers/metadata-diff/DiscardLocalChangesHandler";
import * as DiscardFolderChangesHandler from "../../../../power-pages/actions-hub/handlers/metadata-diff/DiscardFolderChangesHandler";
+import * as GenerateHtmlReportHandler from "../../../../power-pages/actions-hub/handlers/metadata-diff/GenerateHtmlReportHandler";
+import * as ExportMetadataDiffHandler from "../../../../power-pages/actions-hub/handlers/metadata-diff/ExportMetadataDiffHandler";
+import * as ImportMetadataDiffHandler from "../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler";
+import * as ResyncMetadataDiffHandler from "../../../../power-pages/actions-hub/handlers/metadata-diff/ResyncMetadataDiffHandler";
import { ActionsHub } from "../../../../power-pages/actions-hub/ActionsHub";
// Add global type declaration for ArtemisContext
@@ -575,6 +579,89 @@ describe("ActionsHubTreeDataProvider", () => {
}
});
+ it('should register generateHtmlReport command', async () => {
+ sinon.stub(ActionsHub, 'isMetadataDiffEnabled').returns(true);
+ const mockCommandHandler = sinon.stub(GenerateHtmlReportHandler, 'generateHtmlReport');
+ mockCommandHandler.resolves();
+ const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false);
+ actionsHubTreeDataProvider["registerPanel"]();
+
+ expect(registerCommandStub.calledWith(Constants.Commands.METADATA_DIFF_GENERATE_HTML_REPORT)).to.be.true;
+
+ const generateHtmlReportCall = registerCommandStub.getCalls().find(call =>
+ call.args[0] === Constants.Commands.METADATA_DIFF_GENERATE_HTML_REPORT
+ );
+
+ if (generateHtmlReportCall) {
+ await generateHtmlReportCall.args[1]();
+ expect(mockCommandHandler.calledOnce).to.be.true;
+ } else {
+ throw new Error("generateHtmlReport command was not registered");
+ }
+ });
+
+ it('should register exportMetadataDiff command', async () => {
+ sinon.stub(ActionsHub, 'isMetadataDiffEnabled').returns(true);
+ const mockCommandHandler = sinon.stub(ExportMetadataDiffHandler, 'exportMetadataDiff');
+ mockCommandHandler.resolves();
+ const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false);
+ actionsHubTreeDataProvider["registerPanel"]();
+
+ expect(registerCommandStub.calledWith(Constants.Commands.METADATA_DIFF_EXPORT)).to.be.true;
+
+ const exportCall = registerCommandStub.getCalls().find(call =>
+ call.args[0] === Constants.Commands.METADATA_DIFF_EXPORT
+ );
+
+ if (exportCall) {
+ await exportCall.args[1]();
+ expect(mockCommandHandler.calledOnce).to.be.true;
+ } else {
+ throw new Error("exportMetadataDiff command was not registered");
+ }
+ });
+
+ it('should register importMetadataDiff command', async () => {
+ sinon.stub(ActionsHub, 'isMetadataDiffEnabled').returns(true);
+ const mockCommandHandler = sinon.stub(ImportMetadataDiffHandler, 'importMetadataDiff');
+ mockCommandHandler.resolves();
+ const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false);
+ actionsHubTreeDataProvider["registerPanel"]();
+
+ expect(registerCommandStub.calledWith(Constants.Commands.METADATA_DIFF_IMPORT)).to.be.true;
+
+ const importCall = registerCommandStub.getCalls().find(call =>
+ call.args[0] === Constants.Commands.METADATA_DIFF_IMPORT
+ );
+
+ if (importCall) {
+ await importCall.args[1]();
+ expect(mockCommandHandler.calledOnce).to.be.true;
+ } else {
+ throw new Error("importMetadataDiff command was not registered");
+ }
+ });
+
+ it('should register resyncMetadataDiff command', async () => {
+ sinon.stub(ActionsHub, 'isMetadataDiffEnabled').returns(true);
+ const innerHandler = sinon.stub();
+ sinon.stub(ResyncMetadataDiffHandler, 'resyncMetadataDiff').returns(innerHandler);
+ const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false);
+ actionsHubTreeDataProvider["registerPanel"]();
+
+ expect(registerCommandStub.calledWith(Constants.Commands.METADATA_DIFF_RESYNC)).to.be.true;
+
+ const resyncCall = registerCommandStub.getCalls().find(call =>
+ call.args[0] === Constants.Commands.METADATA_DIFF_RESYNC
+ );
+
+ if (resyncCall) {
+ expect(resyncCall.args[1]).to.equal(innerHandler);
+ } else {
+ throw new Error("resyncMetadataDiff command was not registered");
+ }
+ });
+
it('should register showOutputChannel command', async () => {
sinon.stub(ActionsHub, 'isMetadataDiffEnabled').returns(true);
const showOutputChannelStub = sinon.stub();
diff --git a/src/client/test/Integration/power-pages/actions-hub/ActionsHubUtils.test.ts b/src/client/test/Integration/power-pages/actions-hub/ActionsHubUtils.test.ts
index 58d42967b..57a72cffc 100644
--- a/src/client/test/Integration/power-pages/actions-hub/ActionsHubUtils.test.ts
+++ b/src/client/test/Integration/power-pages/actions-hub/ActionsHubUtils.test.ts
@@ -9,15 +9,15 @@ import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
-import { fetchWebsites, findOtherSites, createKnownSiteIdsSet, getAllFiles } from '../../../../power-pages/actions-hub/ActionsHubUtils';
+import { fetchWebsites, findOtherSites, createKnownSiteIdsSet, getAllFiles, isBinaryFile } from '../../../../power-pages/actions-hub/ActionsHubUtils';
import { Constants } from '../../../../power-pages/actions-hub/Constants';
import { WebsiteDataModel, ServiceEndpointCategory } from '../../../../../common/services/Constants';
import { IWebsiteDetails, IArtemisAPIOrgResponse } from '../../../../../common/services/Interfaces';
-import PacContext from '../../../../pac/PacContext';
import ArtemisContext from '../../../../ArtemisContext';
import * as WebsiteUtils from '../../../../../common/utilities/WebsiteUtil';
import * as WorkspaceInfoFinderUtil from '../../../../../common/utilities/WorkspaceInfoFinderUtil';
import * as TelemetryHelper from '../../../../power-pages/actions-hub/TelemetryHelper';
+import { OrgInfo } from '../../../../pac/PacTypes';
describe('ActionsHubUtils', () => {
let sandbox: sinon.SinonSandbox;
@@ -51,25 +51,6 @@ describe('ActionsHubUtils', () => {
ArtemisContext["_artemisResponse"] = { stamp: ServiceEndpointCategory.TEST, response: artemisResponse };
mockGetActiveWebsites = sandbox.stub(WebsiteUtils, 'getActiveWebsites');
mockGetAllWebsites = sandbox.stub(WebsiteUtils, 'getAllWebsites');
- sinon.stub(PacContext, "OrgInfo").get(() => ({ OrgId: 'test-org-id', EnvironmentId: 'test-env-id' }));
- });
-
- it('should not call getActiveWebsites and getAllWebsites', async () => {
- sinon.stub(PacContext, "OrgInfo").get(() => undefined);
-
- await fetchWebsites();
-
- expect(mockGetActiveWebsites.called).to.be.false;
- expect(mockGetAllWebsites.called).to.be.false;
- });
-
- it('should return empty response when orgInfo is null', async () => {
- sinon.stub(PacContext, "OrgInfo").get(() => undefined);
-
- const response = await fetchWebsites();
-
- expect(response.activeSites).to.be.empty;
- expect(response.inactiveSites).to.be.empty;
});
it('should log the error when there is problem is fetching websites', async () => {
@@ -86,7 +67,7 @@ describe('ActionsHubUtils', () => {
mockGetActiveWebsites.resolves(activeSites);
mockGetAllWebsites.rejects(new Error('Test error'));
- const response = await fetchWebsites();
+ const response = await fetchWebsites({} as OrgInfo, true);
expect(response.activeSites).to.be.empty;
expect(response.inactiveSites).to.be.empty;
@@ -127,7 +108,7 @@ describe('ActionsHubUtils', () => {
mockGetActiveWebsites.resolves(activeSites);
mockGetAllWebsites.resolves(allSites);
- const response = await fetchWebsites();
+ const response = await fetchWebsites({} as OrgInfo, true);
expect(response.activeSites).to.deep.equal([...activeSites.map(site => ({ ...site, isCodeSite: false, siteManagementUrl: "https://portalmanagement.com", createdOn: "2025-03-20", creator: "Test Creator" }))]);
expect(response.inactiveSites).to.deep.equal(inactiveSites);
@@ -363,4 +344,121 @@ describe('ActionsHubUtils', () => {
expect(absolutePath).to.equal(path.join(tempDir, 'test.txt'));
});
});
+
+ describe('isBinaryFile', () => {
+ describe('image files', () => {
+ it('should return true for common image formats', () => {
+ expect(isBinaryFile("image.png")).to.be.true;
+ expect(isBinaryFile("folder/image.png")).to.be.true;
+ expect(isBinaryFile("image.jpg")).to.be.true;
+ expect(isBinaryFile("image.jpeg")).to.be.true;
+ expect(isBinaryFile("animation.gif")).to.be.true;
+ expect(isBinaryFile("favicon.ico")).to.be.true;
+ expect(isBinaryFile("image.webp")).to.be.true;
+ expect(isBinaryFile("bitmap.bmp")).to.be.true;
+ });
+
+ it('should treat SVG as binary by default (for diff viewing)', () => {
+ expect(isBinaryFile("icon.svg")).to.be.true;
+ expect(isBinaryFile("folder/icon.svg")).to.be.true;
+ });
+
+ it('should treat SVG as text when includeSvg is false (for export)', () => {
+ expect(isBinaryFile("icon.svg", false)).to.be.false;
+ expect(isBinaryFile("folder/icon.svg", false)).to.be.false;
+ });
+ });
+
+ describe('font files', () => {
+ it('should return true for font formats', () => {
+ expect(isBinaryFile("font.woff")).to.be.true;
+ expect(isBinaryFile("font.woff2")).to.be.true;
+ expect(isBinaryFile("font.ttf")).to.be.true;
+ expect(isBinaryFile("font.otf")).to.be.true;
+ expect(isBinaryFile("font.eot")).to.be.true;
+ });
+ });
+
+ describe('media files', () => {
+ it('should return true for media formats', () => {
+ expect(isBinaryFile("video.mp4")).to.be.true;
+ expect(isBinaryFile("audio.mp3")).to.be.true;
+ expect(isBinaryFile("audio.wav")).to.be.true;
+ expect(isBinaryFile("audio.ogg")).to.be.true;
+ });
+ });
+
+ describe('document and archive files', () => {
+ it('should return true for document formats', () => {
+ expect(isBinaryFile("document.pdf")).to.be.true;
+ expect(isBinaryFile("document.doc")).to.be.true;
+ expect(isBinaryFile("document.docx")).to.be.true;
+ });
+
+ it('should return true for archive formats', () => {
+ expect(isBinaryFile("archive.zip")).to.be.true;
+ expect(isBinaryFile("archive.rar")).to.be.true;
+ expect(isBinaryFile("archive.7z")).to.be.true;
+ });
+ });
+
+ describe('text files', () => {
+ it('should return false for text formats', () => {
+ expect(isBinaryFile("page.html")).to.be.false;
+ expect(isBinaryFile("styles.css")).to.be.false;
+ expect(isBinaryFile("script.js")).to.be.false;
+ expect(isBinaryFile("data.json")).to.be.false;
+ expect(isBinaryFile("config.xml")).to.be.false;
+ expect(isBinaryFile("readme.txt")).to.be.false;
+ expect(isBinaryFile("config.yml")).to.be.false;
+ expect(isBinaryFile("config.yaml")).to.be.false;
+ expect(isBinaryFile("readme.md")).to.be.false;
+ });
+ });
+
+ describe('case insensitivity', () => {
+ it('should handle uppercase extensions', () => {
+ expect(isBinaryFile("IMAGE.PNG")).to.be.true;
+ expect(isBinaryFile("IMAGE.JPG")).to.be.true;
+ });
+
+ it('should handle mixed case extensions', () => {
+ expect(isBinaryFile("image.Png")).to.be.true;
+ expect(isBinaryFile("folder/IMAGE.JpG")).to.be.true;
+ });
+ });
+
+ describe('edge cases', () => {
+ it('should return false for files without extension', () => {
+ expect(isBinaryFile("Makefile")).to.be.false;
+ expect(isBinaryFile("README")).to.be.false;
+ });
+
+ it('should handle files with multiple dots', () => {
+ expect(isBinaryFile("file.backup.png")).to.be.true;
+ expect(isBinaryFile("archive.tar.gz")).to.be.true;
+ });
+
+ it('should handle paths with dots in folder names', () => {
+ expect(isBinaryFile("folder.name/image.png")).to.be.true;
+ expect(isBinaryFile(".hidden/file.txt")).to.be.false;
+ });
+
+ it('should handle empty string', () => {
+ expect(isBinaryFile("")).to.be.false;
+ });
+ });
+
+ describe('file paths', () => {
+ it('should handle Unix-style paths', () => {
+ expect(isBinaryFile("folder/subfolder/image.png")).to.be.true;
+ expect(isBinaryFile("/absolute/path/image.jpg")).to.be.true;
+ });
+
+ it('should handle Windows-style paths', () => {
+ expect(isBinaryFile("folder\\subfolder\\image.png")).to.be.true;
+ expect(isBinaryFile("C:\\Users\\test\\image.jpg")).to.be.true;
+ });
+ });
+ });
});
diff --git a/src/client/test/Integration/power-pages/actions-hub/MetadataDiffContext.test.ts b/src/client/test/Integration/power-pages/actions-hub/MetadataDiffContext.test.ts
index 2834aa944..2f856711f 100644
--- a/src/client/test/Integration/power-pages/actions-hub/MetadataDiffContext.test.ts
+++ b/src/client/test/Integration/power-pages/actions-hub/MetadataDiffContext.test.ts
@@ -52,7 +52,7 @@ describe("MetadataDiffContext", () => {
}
];
- MetadataDiffContext.setResults(results, "Test Site", "Test Environment");
+ MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id");
expect(MetadataDiffContext.comparisonResults).to.deep.equal(results);
});
@@ -67,7 +67,7 @@ describe("MetadataDiffContext", () => {
}
];
- MetadataDiffContext.setResults(results, "My Test Site", "Test Environment");
+ MetadataDiffContext.setResults(results, "My Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id");
expect(MetadataDiffContext.siteName).to.equal("My Test Site");
});
@@ -82,13 +82,13 @@ describe("MetadataDiffContext", () => {
}
];
- MetadataDiffContext.setResults(results, "Test Site", "Test Environment");
+ MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id");
expect(MetadataDiffContext.isActive).to.be.true;
});
it("should set isActive to false when results are empty", () => {
- MetadataDiffContext.setResults([], "Test Site", "Test Environment");
+ MetadataDiffContext.setResults([], "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id");
expect(MetadataDiffContext.isActive).to.be.false;
});
@@ -106,7 +106,7 @@ describe("MetadataDiffContext", () => {
}
];
- MetadataDiffContext.setResults(results, "Test Site", "Test Environment");
+ MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id");
expect(onChangedSpy.calledOnce).to.be.true;
});
@@ -122,7 +122,7 @@ describe("MetadataDiffContext", () => {
status: "modified"
}
];
- MetadataDiffContext.setResults(results, "Test Site", "Test Environment");
+ MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id");
MetadataDiffContext.clear();
@@ -138,7 +138,7 @@ describe("MetadataDiffContext", () => {
status: "modified"
}
];
- MetadataDiffContext.setResults(results, "Test Site", "Test Environment");
+ MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id");
MetadataDiffContext.clear();
@@ -154,7 +154,7 @@ describe("MetadataDiffContext", () => {
status: "modified"
}
];
- MetadataDiffContext.setResults(results, "Test Site", "Test Environment");
+ MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id");
MetadataDiffContext.clear();
@@ -386,7 +386,7 @@ describe("MetadataDiffContext", () => {
}
];
- MetadataDiffContext.setResults(results, "Test Site", "Test Environment");
+ MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id");
expect(MetadataDiffContext.comparisonResults).to.have.lengthOf(3);
expect(MetadataDiffContext.comparisonResults[0].status).to.equal("modified");
@@ -394,4 +394,170 @@ describe("MetadataDiffContext", () => {
expect(MetadataDiffContext.comparisonResults[2].status).to.equal("deleted");
});
});
+
+ describe("getUniqueKey", () => {
+ it("should generate key for live comparison", () => {
+ const key = MetadataDiffContext.getUniqueKey("website-123", "env-456", false);
+ expect(key).to.equal("website-123_env-456");
+ });
+
+ it("should generate key with _imported suffix for imported comparison", () => {
+ const key = MetadataDiffContext.getUniqueKey("website-123", "env-456", true);
+ expect(key).to.equal("website-123_env-456_imported");
+ });
+
+ it("should default isImported to false", () => {
+ const key = MetadataDiffContext.getUniqueKey("website-123", "env-456");
+ expect(key).to.equal("website-123_env-456");
+ });
+ });
+
+ describe("hasImportedComparison", () => {
+ it("should return false when no imported comparison exists", () => {
+ expect(MetadataDiffContext.hasImportedComparison("website-123", "env-456")).to.be.false;
+ });
+
+ it("should return true when imported comparison exists", () => {
+ const results: IFileComparisonResult[] = [
+ {
+ localPath: "/local/file.txt",
+ remotePath: "/remote/file.txt",
+ relativePath: "file.txt",
+ status: "modified"
+ }
+ ];
+ MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "website-123", "env-456", true, "2024-01-15T10:30:00Z");
+
+ expect(MetadataDiffContext.hasImportedComparison("website-123", "env-456")).to.be.true;
+ });
+
+ it("should return false when only live comparison exists", () => {
+ const results: IFileComparisonResult[] = [
+ {
+ localPath: "/local/file.txt",
+ remotePath: "/remote/file.txt",
+ relativePath: "file.txt",
+ status: "modified"
+ }
+ ];
+ MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "website-123", "env-456", false);
+
+ expect(MetadataDiffContext.hasImportedComparison("website-123", "env-456")).to.be.false;
+ });
+ });
+
+ describe("clearSiteByKey", () => {
+ it("should clear live comparison by key", () => {
+ const results: IFileComparisonResult[] = [
+ {
+ localPath: "/local/file.txt",
+ remotePath: "/remote/file.txt",
+ relativePath: "file.txt",
+ status: "modified"
+ }
+ ];
+ MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "website-123", "env-456", false);
+
+ expect(MetadataDiffContext.isActive).to.be.true;
+
+ MetadataDiffContext.clearSiteByKey("website-123", "env-456", false);
+
+ expect(MetadataDiffContext.isActive).to.be.false;
+ });
+
+ it("should clear imported comparison by key", () => {
+ const results: IFileComparisonResult[] = [
+ {
+ localPath: "/local/file.txt",
+ remotePath: "/remote/file.txt",
+ relativePath: "file.txt",
+ status: "modified"
+ }
+ ];
+ MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "website-123", "env-456", true, "2024-01-15T10:30:00Z");
+
+ expect(MetadataDiffContext.hasImportedComparison("website-123", "env-456")).to.be.true;
+
+ MetadataDiffContext.clearSiteByKey("website-123", "env-456", true);
+
+ expect(MetadataDiffContext.hasImportedComparison("website-123", "env-456")).to.be.false;
+ });
+
+ it("should only clear specified comparison type", () => {
+ const results: IFileComparisonResult[] = [
+ {
+ localPath: "/local/file.txt",
+ remotePath: "/remote/file.txt",
+ relativePath: "file.txt",
+ status: "modified"
+ }
+ ];
+ // Add both live and imported comparisons
+ MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "website-123", "env-456", false);
+ MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "website-123", "env-456", true, "2024-01-15T10:30:00Z");
+
+ expect(MetadataDiffContext.allSiteResults).to.have.lengthOf(2);
+
+ // Clear only the imported one
+ MetadataDiffContext.clearSiteByKey("website-123", "env-456", true);
+
+ expect(MetadataDiffContext.allSiteResults).to.have.lengthOf(1);
+ expect(MetadataDiffContext.hasImportedComparison("website-123", "env-456")).to.be.false;
+ expect(MetadataDiffContext.isActive).to.be.true;
+ });
+
+ it("should fire onChanged event when clearing", () => {
+ const results: IFileComparisonResult[] = [
+ {
+ localPath: "/local/file.txt",
+ remotePath: "/remote/file.txt",
+ relativePath: "file.txt",
+ status: "modified"
+ }
+ ];
+ MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "website-123", "env-456", false);
+
+ const onChangedSpy = sandbox.spy();
+ MetadataDiffContext.onChanged(onChangedSpy);
+
+ MetadataDiffContext.clearSiteByKey("website-123", "env-456", false);
+
+ expect(onChangedSpy.calledOnce).to.be.true;
+ });
+ });
+
+ describe("imported comparisons", () => {
+ it("should store imported comparison with exportedAt timestamp", () => {
+ const results: IFileComparisonResult[] = [
+ {
+ localPath: "/local/file.txt",
+ remotePath: "/remote/file.txt",
+ relativePath: "file.txt",
+ status: "modified"
+ }
+ ];
+ const exportedAt = "2024-01-15T10:30:00Z";
+ MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "website-123", "env-456", true, exportedAt);
+
+ const siteResults = MetadataDiffContext.allSiteResults;
+ expect(siteResults).to.have.lengthOf(1);
+ expect(siteResults[0].isImported).to.be.true;
+ expect(siteResults[0].exportedAt).to.equal(exportedAt);
+ });
+
+ it("should allow both live and imported comparisons for same site", () => {
+ const results: IFileComparisonResult[] = [
+ {
+ localPath: "/local/file.txt",
+ remotePath: "/remote/file.txt",
+ relativePath: "file.txt",
+ status: "modified"
+ }
+ ];
+ MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "website-123", "env-456", false);
+ MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "website-123", "env-456", true, "2024-01-15T10:30:00Z");
+
+ expect(MetadataDiffContext.allSiteResults).to.have.lengthOf(2);
+ });
+ });
});
diff --git a/src/client/test/Integration/power-pages/actions-hub/handlers/ShowEnvironmentDetailsHandler.test.ts b/src/client/test/Integration/power-pages/actions-hub/handlers/ShowEnvironmentDetailsHandler.test.ts
index 2d185a94e..a5ed3fdb0 100644
--- a/src/client/test/Integration/power-pages/actions-hub/handlers/ShowEnvironmentDetailsHandler.test.ts
+++ b/src/client/test/Integration/power-pages/actions-hub/handlers/ShowEnvironmentDetailsHandler.test.ts
@@ -19,6 +19,8 @@ describe('ShowEnvironmentDetailsHandler', () => {
let sandbox: sinon.SinonSandbox;
let mockShowInformationMessage: sinon.SinonStub;
let traceErrorStub: sinon.SinonStub;
+ let originalClipboard: typeof vscode.env.clipboard;
+ let mockWriteText: sinon.SinonStub;
const mockAuthInfo: AuthInfo = {
UserType: 'user-type',
@@ -63,16 +65,29 @@ describe('ShowEnvironmentDetailsHandler', () => {
sandbox.stub(TelemetryHelper, "getBaseEventInfo").returns({ foo: 'bar' });
sandbox.stub(TelemetryHelper, "traceInfo");
sandbox.stub(vscode.env, 'sessionId').get(() => 'test-session-id');
+
+ // Save original clipboard and create mock
+ originalClipboard = vscode.env.clipboard;
+ mockWriteText = sandbox.stub().resolves();
+ Object.defineProperty(vscode.env, 'clipboard', {
+ value: { writeText: mockWriteText, readText: sandbox.stub().resolves('') },
+ configurable: true
+ });
});
afterEach(() => {
+ // Restore original clipboard
+ Object.defineProperty(vscode.env, 'clipboard', {
+ value: originalClipboard,
+ configurable: true
+ });
sandbox.restore();
});
describe('showEnvironmentDetails', () => {
it('should show information notification', async () => {
PacContext['_authInfo'] = mockAuthInfo;
- mockShowInformationMessage.resolves(Constants.Strings.COPY_TO_CLIPBOARD);
+ mockShowInformationMessage.resolves(undefined);
await showEnvironmentDetails();
@@ -81,7 +96,7 @@ describe('ShowEnvironmentDetailsHandler', () => {
it('should have expected heading', async () => {
PacContext['_authInfo'] = mockAuthInfo;
- mockShowInformationMessage.resolves(Constants.Strings.COPY_TO_CLIPBOARD);
+ mockShowInformationMessage.resolves(undefined);
await showEnvironmentDetails();
@@ -91,7 +106,7 @@ describe('ShowEnvironmentDetailsHandler', () => {
it('should be rendered as modal', async () => {
PacContext['_authInfo'] = mockAuthInfo;
- mockShowInformationMessage.resolves(Constants.Strings.COPY_TO_CLIPBOARD);
+ mockShowInformationMessage.resolves(undefined);
await showEnvironmentDetails();
@@ -102,7 +117,7 @@ describe('ShowEnvironmentDetailsHandler', () => {
it('should show environment details when auth info is available', async () => {
PacContext['_authInfo'] = mockAuthInfo;
PacContext['_orgInfo'] = mockOrgInfo;
- mockShowInformationMessage.resolves(Constants.Strings.COPY_TO_CLIPBOARD);
+ mockShowInformationMessage.resolves(undefined);
await showEnvironmentDetails();
@@ -124,7 +139,7 @@ describe('ShowEnvironmentDetailsHandler', () => {
it('should handle cases without auth info', async () => {
PacContext['_authInfo'] = null;
- mockShowInformationMessage.resolves(Constants.Strings.COPY_TO_CLIPBOARD);
+ mockShowInformationMessage.resolves(undefined);
await showEnvironmentDetails();
@@ -139,5 +154,27 @@ describe('ShowEnvironmentDetailsHandler', () => {
expect(traceErrorStub.calledOnce).to.be.true;
expect(traceErrorStub.firstCall.args[0]).to.equal(Constants.EventNames.ACTIONS_HUB_SHOW_ENVIRONMENT_DETAILS_FAILED);
});
+
+ it('should copy details to clipboard when user clicks copy button', async () => {
+ PacContext['_authInfo'] = mockAuthInfo;
+ PacContext['_orgInfo'] = mockOrgInfo;
+ mockShowInformationMessage.resolves(Constants.Strings.COPY_TO_CLIPBOARD);
+
+ await showEnvironmentDetails();
+
+ expect(mockWriteText.calledOnce).to.be.true;
+ const clipboardContent = mockWriteText.firstCall.args[0];
+ expect(clipboardContent).to.include("Session ID: test-session-id");
+ expect(clipboardContent).to.include("Tenant ID: test-tenant");
+ });
+
+ it('should not copy to clipboard when user dismisses dialog', async () => {
+ PacContext['_authInfo'] = mockAuthInfo;
+ mockShowInformationMessage.resolves(undefined);
+
+ await showEnvironmentDetails();
+
+ expect(mockWriteText.called).to.be.false;
+ });
});
});
diff --git a/src/client/test/Integration/power-pages/actions-hub/handlers/ShowSiteDetailsHandler.test.ts b/src/client/test/Integration/power-pages/actions-hub/handlers/ShowSiteDetailsHandler.test.ts
index 9d7cba2ff..fde1e1be9 100644
--- a/src/client/test/Integration/power-pages/actions-hub/handlers/ShowSiteDetailsHandler.test.ts
+++ b/src/client/test/Integration/power-pages/actions-hub/handlers/ShowSiteDetailsHandler.test.ts
@@ -16,23 +16,39 @@ import * as TelemetryHelper from '../../../../../power-pages/actions-hub/Telemet
describe('ShowSiteDetailsHandler', () => {
let sandbox: sinon.SinonSandbox;
let mockShowInformationMessage: sinon.SinonStub;
+ let traceInfoStub: sinon.SinonStub;
+ let originalClipboard: typeof vscode.env.clipboard;
+ let mockWriteText: sinon.SinonStub;
beforeEach(() => {
sandbox = sinon.createSandbox();
mockShowInformationMessage = sandbox.stub(vscode.window, 'showInformationMessage');
sandbox.stub(TelemetryHelper, 'traceError');
sandbox.stub(TelemetryHelper, "getBaseEventInfo").returns({ foo: 'bar' });
- sandbox.stub(TelemetryHelper, "traceInfo");
+ traceInfoStub = sandbox.stub(TelemetryHelper, "traceInfo");
sandbox.stub(vscode.env, 'sessionId').get(() => 'test-session-id');
+
+ // Save original clipboard and create mock
+ originalClipboard = vscode.env.clipboard;
+ mockWriteText = sandbox.stub().resolves();
+ Object.defineProperty(vscode.env, 'clipboard', {
+ value: { writeText: mockWriteText, readText: sandbox.stub().resolves('') },
+ configurable: true
+ });
});
afterEach(() => {
+ // Restore original clipboard
+ Object.defineProperty(vscode.env, 'clipboard', {
+ value: originalClipboard,
+ configurable: true
+ });
sandbox.restore();
});
describe('showSiteDetails', () => {
it('should show information notification', async () => {
- mockShowInformationMessage.resolves(Constants.Strings.COPY_TO_CLIPBOARD);
+ mockShowInformationMessage.resolves(undefined);
await showSiteDetails({
siteInfo: {
@@ -46,7 +62,7 @@ describe('ShowSiteDetailsHandler', () => {
});
it('should have expected heading', async () => {
- mockShowInformationMessage.resolves(Constants.Strings.COPY_TO_CLIPBOARD);
+ mockShowInformationMessage.resolves(undefined);
await showSiteDetails({
siteInfo: {
@@ -61,7 +77,7 @@ describe('ShowSiteDetailsHandler', () => {
});
it('should be rendered as modal', async () => {
- mockShowInformationMessage.resolves(Constants.Strings.COPY_TO_CLIPBOARD);
+ mockShowInformationMessage.resolves(undefined);
await showSiteDetails({
siteInfo: {
@@ -76,7 +92,7 @@ describe('ShowSiteDetailsHandler', () => {
});
it('should show site details', async () => {
- mockShowInformationMessage.resolves(Constants.Strings.COPY_TO_CLIPBOARD);
+ mockShowInformationMessage.resolves(undefined);
await showSiteDetails({
siteInfo: {
@@ -101,5 +117,54 @@ describe('ShowSiteDetailsHandler', () => {
expect(message).to.include("Creator: Test Creator");
expect(message).to.include("Created on: March 20, 2025");
});
+
+ it('should copy details to clipboard when user clicks copy button', async () => {
+ mockShowInformationMessage.resolves(Constants.Strings.COPY_TO_CLIPBOARD);
+
+ await showSiteDetails({
+ siteInfo: {
+ name: "Test Site",
+ websiteId: "test-id",
+ dataModelVersion: 1,
+ websiteUrl: 'https://test-site.com',
+ siteVisibility: SiteVisibility.Public,
+ createdOn: "2025-03-20T00:00:00Z",
+ creator: "Test Creator"
+ } as IWebsiteInfo
+ } as SiteTreeItem);
+
+ expect(mockWriteText.calledOnce).to.be.true;
+ const clipboardContent = mockWriteText.firstCall.args[0];
+ expect(clipboardContent).to.include("Friendly name: Test Site");
+ expect(clipboardContent).to.include("Website Id: test-id");
+ });
+
+ it('should log telemetry when user clicks copy button', async () => {
+ mockShowInformationMessage.resolves(Constants.Strings.COPY_TO_CLIPBOARD);
+
+ await showSiteDetails({
+ siteInfo: {
+ name: "Test Site",
+ websiteId: "test-id",
+ dataModelVersion: 1
+ } as IWebsiteInfo
+ } as SiteTreeItem);
+
+ expect(traceInfoStub.calledWith(Constants.EventNames.ACTIONS_HUB_SHOW_SITE_DETAILS_COPY_TO_CLIPBOARD)).to.be.true;
+ });
+
+ it('should not copy to clipboard when user dismisses dialog', async () => {
+ mockShowInformationMessage.resolves(undefined);
+
+ await showSiteDetails({
+ siteInfo: {
+ name: "Test Site",
+ websiteId: "test-id",
+ dataModelVersion: 1
+ } as IWebsiteInfo
+ } as SiteTreeItem);
+
+ expect(mockWriteText.called).to.be.false;
+ });
});
});
diff --git a/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/ClearMetadataDiffHandler.test.ts b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/ClearMetadataDiffHandler.test.ts
index 816613256..080dc6d28 100644
--- a/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/ClearMetadataDiffHandler.test.ts
+++ b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/ClearMetadataDiffHandler.test.ts
@@ -35,7 +35,7 @@ describe("ClearMetadataDiffHandler", () => {
status: "modified"
}
];
- MetadataDiffContext.setResults(results, "Test Site", "Test Environment");
+ MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id");
clearMetadataDiff();
diff --git a/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/CompareWithEnvironmentHandler.test.ts b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/CompareWithEnvironmentHandler.test.ts
index fd4284650..a98754def 100644
--- a/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/CompareWithEnvironmentHandler.test.ts
+++ b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/CompareWithEnvironmentHandler.test.ts
@@ -10,8 +10,11 @@ import { compareWithEnvironment } from "../../../../../../power-pages/actions-hu
import { Constants } from "../../../../../../power-pages/actions-hub/Constants";
import { PacTerminal } from "../../../../../../lib/PacTerminal";
import MetadataDiffContext from "../../../../../../power-pages/actions-hub/MetadataDiffContext";
+import PacContext from "../../../../../../pac/PacContext";
import * as TelemetryHelper from "../../../../../../power-pages/actions-hub/TelemetryHelper";
import * as WorkspaceInfoFinderUtil from "../../../../../../../common/utilities/WorkspaceInfoFinderUtil";
+import * as ActionsHubUtils from "../../../../../../power-pages/actions-hub/ActionsHubUtils";
+import { IWebsiteDetails } from "../../../../../../../common/services/Interfaces";
import { SUCCESS } from "../../../../../../../common/constants";
describe("CompareWithEnvironmentHandler", () => {
@@ -50,11 +53,23 @@ describe("CompareWithEnvironmentHandler", () => {
// Mock ExtensionContext
mockExtensionContext = {
- storageUri: { fsPath: "/test/storage/path" }
+ storageUri: { fsPath: "/test/storage/path" },
+ extensionUri: vscode.Uri.file("/test/extension")
} as unknown as vscode.ExtensionContext;
// Clear MetadataDiffContext before each test
MetadataDiffContext.clear();
+
+ // Mock PacContext with current environment
+ sandbox.stub(PacContext, "OrgInfo").get(() => ({
+ OrgId: "current-org-id",
+ UniqueName: "currentorg",
+ FriendlyName: "Current Environment",
+ OrgUrl: "https://current.crm.dynamics.com",
+ UserEmail: "",
+ UserId: "",
+ EnvironmentId: "current-env-id"
+ }));
});
afterEach(() => {
@@ -137,12 +152,15 @@ describe("CompareWithEnvironmentHandler", () => {
{
FriendlyName: "Test Environment",
EnvironmentId: "env-id-1",
- EnvironmentUrl: "https://test.crm.dynamics.com"
+ EnvironmentUrl: "https://test.crm.dynamics.com",
+ OrganizationId: "org-id-1",
+ UniqueName: "testorg",
+ EnvironmentIdentifier: { Id: "env-id-1" }
}
]
});
- mockShowQuickPick.resolves(undefined); // User cancelled
+ mockShowQuickPick.resolves(undefined); // User cancelled environment selection
});
it("should log cancellation telemetry", async () => {
@@ -160,6 +178,139 @@ describe("CompareWithEnvironmentHandler", () => {
});
});
+ describe("when user cancels website selection", () => {
+ beforeEach(() => {
+ sandbox.stub(vscode.workspace, "workspaceFolders").get(() => [
+ { uri: { fsPath: "/test/workspace" }, name: "workspace", index: 0 }
+ ]);
+ sandbox.stub(WorkspaceInfoFinderUtil, "getWebsiteRecordId").returns("test-website-id");
+ sandbox.stub(WorkspaceInfoFinderUtil, "findPowerPagesSiteFolder").returns(null);
+
+ mockPacWrapper.orgList.resolves({
+ Status: SUCCESS,
+ Results: [
+ {
+ FriendlyName: "Test Environment",
+ EnvironmentId: "env-id-1",
+ EnvironmentUrl: "https://test.crm.dynamics.com",
+ OrganizationId: "org-id-1",
+ UniqueName: "testorg",
+ EnvironmentIdentifier: { Id: "env-id-1" }
+ }
+ ]
+ });
+
+ sandbox.stub(ActionsHubUtils, "fetchWebsites").resolves({
+ activeSites: [
+ {
+ name: "Test Website",
+ websiteRecordId: "website-id-1",
+ websiteUrl: "https://test.powerappsportals.com",
+ dataModel: "Enhanced"
+ }
+ ] as unknown as IWebsiteDetails[],
+ inactiveSites: [],
+ otherSites: []
+ });
+
+ // First call returns environment selection, second call returns undefined (cancelled website selection)
+ mockShowQuickPick
+ .onFirstCall().resolves({
+ label: "Test Environment",
+ detail: "https://test.crm.dynamics.com",
+ orgInfo: {
+ OrgId: "org-id-1",
+ UniqueName: "testorg",
+ FriendlyName: "Test Environment",
+ OrgUrl: "https://test.crm.dynamics.com",
+ UserEmail: "",
+ UserId: "",
+ EnvironmentId: "env-id-1"
+ }
+ })
+ .onSecondCall().resolves(undefined); // User cancelled website selection
+ });
+
+ it("should log cancellation telemetry with reason", async () => {
+ const handler = compareWithEnvironment(mockPacTerminal as unknown as PacTerminal, mockExtensionContext);
+ await handler({ fsPath: "/test/workspace" } as vscode.Uri);
+
+ expect(traceInfoStub.calledWith(Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_CANCELLED)).to.be.true;
+ const cancelCall = traceInfoStub.getCalls().find(
+ call => call.args[0] === Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_CANCELLED
+ );
+ expect(cancelCall?.args[1]).to.deep.include({
+ reason: "User cancelled website selection"
+ });
+ });
+
+ it("should not show error message", async () => {
+ const handler = compareWithEnvironment(mockPacTerminal as unknown as PacTerminal, mockExtensionContext);
+ await handler({ fsPath: "/test/workspace" } as vscode.Uri);
+
+ expect(mockShowErrorMessage.called).to.be.false;
+ });
+ });
+
+ describe("when no websites are found in environment", () => {
+ beforeEach(() => {
+ sandbox.stub(vscode.workspace, "workspaceFolders").get(() => [
+ { uri: { fsPath: "/test/workspace" }, name: "workspace", index: 0 }
+ ]);
+ sandbox.stub(WorkspaceInfoFinderUtil, "getWebsiteRecordId").returns("test-website-id");
+ sandbox.stub(WorkspaceInfoFinderUtil, "findPowerPagesSiteFolder").returns(null);
+
+ mockPacWrapper.orgList.resolves({
+ Status: SUCCESS,
+ Results: [
+ {
+ FriendlyName: "Test Environment",
+ EnvironmentId: "env-id-1",
+ EnvironmentUrl: "https://test.crm.dynamics.com",
+ OrganizationId: "org-id-1",
+ UniqueName: "testorg",
+ EnvironmentIdentifier: { Id: "env-id-1" }
+ }
+ ]
+ });
+
+ sandbox.stub(ActionsHubUtils, "fetchWebsites").resolves({
+ activeSites: [],
+ inactiveSites: [],
+ otherSites: []
+ });
+
+ mockShowQuickPick.resolves({
+ label: "Test Environment",
+ detail: "https://test.crm.dynamics.com",
+ orgInfo: {
+ OrgId: "org-id-1",
+ UniqueName: "",
+ FriendlyName: "Test Environment",
+ OrgUrl: "https://test.crm.dynamics.com",
+ UserEmail: "",
+ UserId: "",
+ EnvironmentId: "env-id-1"
+ }
+ });
+ });
+
+ it("should show no sites found error message", async () => {
+ const handler = compareWithEnvironment(mockPacTerminal as unknown as PacTerminal, mockExtensionContext);
+ await handler({ fsPath: "/test/workspace" } as vscode.Uri);
+
+ expect(mockShowErrorMessage.calledOnce).to.be.true;
+ expect(mockShowErrorMessage.firstCall.args[0]).to.equal(Constants.Strings.NO_SITES_FOUND_IN_ENVIRONMENT);
+ });
+
+ it("should log telemetry event", async () => {
+ const handler = compareWithEnvironment(mockPacTerminal as unknown as PacTerminal, mockExtensionContext);
+ await handler({ fsPath: "/test/workspace" } as vscode.Uri);
+
+ expect(traceInfoStub.calledWith(Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_WEBSITE_NOT_FOUND)).to.be.true;
+ });
+ });
+
describe("when no environments are found", () => {
beforeEach(() => {
sandbox.stub(vscode.workspace, "workspaceFolders").get(() => [
@@ -183,6 +334,95 @@ describe("CompareWithEnvironmentHandler", () => {
});
});
+ describe("when only the current environment is available", () => {
+ beforeEach(() => {
+ sandbox.stub(vscode.workspace, "workspaceFolders").get(() => [
+ { uri: { fsPath: "/test/workspace" }, name: "workspace", index: 0 }
+ ]);
+ sandbox.stub(WorkspaceInfoFinderUtil, "getWebsiteRecordId").returns("test-website-id");
+ sandbox.stub(WorkspaceInfoFinderUtil, "findPowerPagesSiteFolder").returns(null);
+
+ // Return only the current environment which should be filtered out
+ mockPacWrapper.orgList.resolves({
+ Status: SUCCESS,
+ Results: [
+ {
+ FriendlyName: "Current Environment",
+ EnvironmentId: "current-env-id",
+ EnvironmentUrl: "https://current.crm.dynamics.com",
+ OrganizationId: "current-org-id",
+ UniqueName: "currentorg",
+ EnvironmentIdentifier: { Id: "current-env-id" }
+ }
+ ]
+ });
+ });
+
+ it("should show no environments error message when current environment is filtered out", async () => {
+ const handler = compareWithEnvironment(mockPacTerminal as unknown as PacTerminal, mockExtensionContext);
+ await handler({ fsPath: "/test/workspace" } as vscode.Uri);
+
+ expect(mockShowErrorMessage.calledOnce).to.be.true;
+ expect(mockShowErrorMessage.firstCall.args[0]).to.equal(Constants.Strings.NO_ENVIRONMENTS_FOUND);
+ });
+ });
+
+ describe("when filtering out the current environment", () => {
+ beforeEach(() => {
+ sandbox.stub(vscode.workspace, "workspaceFolders").get(() => [
+ { uri: { fsPath: "/test/workspace" }, name: "workspace", index: 0 }
+ ]);
+ sandbox.stub(WorkspaceInfoFinderUtil, "getWebsiteRecordId").returns("test-website-id");
+ sandbox.stub(WorkspaceInfoFinderUtil, "findPowerPagesSiteFolder").returns(null);
+
+ // Return multiple environments including the current one
+ mockPacWrapper.orgList.resolves({
+ Status: SUCCESS,
+ Results: [
+ {
+ FriendlyName: "Current Environment",
+ EnvironmentId: "current-env-id",
+ EnvironmentUrl: "https://current.crm.dynamics.com",
+ OrganizationId: "current-org-id",
+ UniqueName: "currentorg",
+ EnvironmentIdentifier: { Id: "current-env-id" }
+ },
+ {
+ FriendlyName: "Other Environment",
+ EnvironmentId: "other-env-id",
+ EnvironmentUrl: "https://other.crm.dynamics.com",
+ OrganizationId: "other-org-id",
+ UniqueName: "otherorg",
+ EnvironmentIdentifier: { Id: "other-env-id" }
+ }
+ ]
+ });
+
+ mockShowQuickPick.resolves(undefined); // User cancels
+ });
+
+ it("should not show the current environment in the quick pick list", async () => {
+ const handler = compareWithEnvironment(mockPacTerminal as unknown as PacTerminal, mockExtensionContext);
+ await handler({ fsPath: "/test/workspace" } as vscode.Uri);
+
+ expect(mockShowQuickPick.calledOnce).to.be.true;
+ const quickPickItems = mockShowQuickPick.firstCall.args[0];
+ expect(quickPickItems).to.be.an("array");
+ expect(quickPickItems.length).to.equal(1);
+ expect(quickPickItems[0].label).to.equal("Other Environment");
+ });
+
+ it("should filter out environment based on EnvironmentId", async () => {
+ const handler = compareWithEnvironment(mockPacTerminal as unknown as PacTerminal, mockExtensionContext);
+ await handler({ fsPath: "/test/workspace" } as vscode.Uri);
+
+ const quickPickItems = mockShowQuickPick.firstCall.args[0];
+ const environmentIds = quickPickItems.map((item: { orgInfo: { EnvironmentId: string } }) => item.orgInfo.EnvironmentId);
+ expect(environmentIds).to.not.include("current-env-id");
+ expect(environmentIds).to.include("other-env-id");
+ });
+ });
+
describe("telemetry", () => {
it("should log initial telemetry event when handler is called", async () => {
sandbox.stub(vscode.workspace, "workspaceFolders").get(() => undefined);
@@ -214,16 +454,53 @@ describe("CompareWithEnvironmentHandler", () => {
{
FriendlyName: "Test Environment",
EnvironmentId: "env-id-1",
- EnvironmentUrl: "https://test.crm.dynamics.com"
+ EnvironmentUrl: "https://test.crm.dynamics.com",
+ OrganizationId: "org-id-1",
+ UniqueName: "testorg",
+ EnvironmentIdentifier: { Id: "env-id-1" }
}
]
});
- mockShowQuickPick.resolves({
- label: "Test Environment",
- detail: "https://test.crm.dynamics.com",
- environmentId: "env-id-1"
+ sandbox.stub(ActionsHubUtils, "fetchWebsites").resolves({
+ activeSites: [
+ {
+ name: "Test Website",
+ websiteRecordId: "website-id-1",
+ websiteUrl: "https://test.powerappsportals.com",
+ dataModel: "Enhanced"
+ }
+ ] as unknown as IWebsiteDetails[],
+ inactiveSites: [],
+ otherSites: []
});
+
+ // First call returns environment selection, second call returns website selection
+ mockShowQuickPick
+ .onFirstCall().resolves({
+ label: "Test Environment",
+ detail: "https://test.crm.dynamics.com",
+ orgInfo: {
+ OrgId: "org-id-1",
+ UniqueName: "testorg",
+ FriendlyName: "Test Environment",
+ OrgUrl: "https://test.crm.dynamics.com",
+ UserEmail: "",
+ UserId: "",
+ EnvironmentId: "env-id-1"
+ }
+ })
+ .onSecondCall().resolves({
+ label: "Test Website",
+ detail: "https://test.powerappsportals.com",
+ description: Constants.Strings.ENHANCED_DATA_MODEL,
+ websiteDetails: {
+ name: "Test Website",
+ websiteRecordId: "website-id-1",
+ websiteUrl: "https://test.powerappsportals.com",
+ dataModel: "Enhanced"
+ }
+ });
});
it("should return early without error when storage path is undefined", async () => {
@@ -242,5 +519,291 @@ describe("CompareWithEnvironmentHandler", () => {
}
});
});
+
+ describe("when user selects a different website", () => {
+ let mockShowWarningMessage: sinon.SinonStub;
+
+ beforeEach(() => {
+ sandbox.stub(vscode.workspace, "workspaceFolders").get(() => [
+ { uri: { fsPath: "/test/workspace" }, name: "workspace", index: 0 }
+ ]);
+ sandbox.stub(WorkspaceInfoFinderUtil, "getWebsiteRecordId").returns("local-website-id");
+ sandbox.stub(WorkspaceInfoFinderUtil, "findPowerPagesSiteFolder").returns(null);
+ mockShowWarningMessage = sandbox.stub(vscode.window, "showWarningMessage");
+
+ mockPacWrapper.orgList.resolves({
+ Status: SUCCESS,
+ Results: [
+ {
+ FriendlyName: "Test Environment",
+ EnvironmentId: "env-id-1",
+ EnvironmentUrl: "https://test.crm.dynamics.com",
+ OrganizationId: "org-id-1",
+ UniqueName: "testorg",
+ EnvironmentIdentifier: { Id: "env-id-1" }
+ }
+ ]
+ });
+
+ sandbox.stub(ActionsHubUtils, "fetchWebsites").resolves({
+ activeSites: [
+ {
+ name: "Different Website",
+ websiteRecordId: "different-website-id",
+ websiteUrl: "https://different.powerappsportals.com",
+ dataModel: "Enhanced"
+ }
+ ] as unknown as IWebsiteDetails[],
+ inactiveSites: [],
+ otherSites: []
+ });
+
+ // First call returns environment selection, second call returns different website
+ mockShowQuickPick
+ .onFirstCall().resolves({
+ label: "Test Environment",
+ detail: "https://test.crm.dynamics.com",
+ orgInfo: {
+ OrgId: "org-id-1",
+ UniqueName: "testorg",
+ FriendlyName: "Test Environment",
+ OrgUrl: "https://test.crm.dynamics.com",
+ UserEmail: "",
+ UserId: "",
+ EnvironmentId: "env-id-1"
+ }
+ })
+ .onSecondCall().resolves({
+ label: "Different Website",
+ detail: "https://different.powerappsportals.com",
+ description: Constants.Strings.ENHANCED_DATA_MODEL,
+ websiteDetails: {
+ name: "Different Website",
+ websiteRecordId: "different-website-id",
+ websiteUrl: "https://different.powerappsportals.com",
+ dataModel: "Enhanced"
+ }
+ });
+ });
+
+ it("should show confirmation dialog when selected website is different from local", async () => {
+ mockShowWarningMessage.resolves(undefined); // User cancelled
+
+ const handler = compareWithEnvironment(mockPacTerminal as unknown as PacTerminal, mockExtensionContext);
+ await handler({ fsPath: "/test/workspace" } as vscode.Uri);
+
+ expect(mockShowWarningMessage.calledOnce).to.be.true;
+ expect(mockShowWarningMessage.firstCall.args[0]).to.equal(Constants.Strings.DIFFERENT_WEBSITE_CONFIRMATION);
+ expect(mockShowWarningMessage.firstCall.args[1]).to.deep.equal({ modal: true });
+ });
+
+ it("should cancel operation when user declines confirmation", async () => {
+ mockShowWarningMessage.resolves(undefined); // User cancelled
+
+ const handler = compareWithEnvironment(mockPacTerminal as unknown as PacTerminal, mockExtensionContext);
+ await handler({ fsPath: "/test/workspace" } as vscode.Uri);
+
+ expect(traceInfoStub.calledWith(Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_CANCELLED)).to.be.true;
+ const cancelCall = traceInfoStub.getCalls().find(
+ call => call.args[0] === Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_CANCELLED
+ );
+ expect(cancelCall?.args[1]).to.deep.include({
+ reason: "User cancelled after different website confirmation"
+ });
+ });
+ });
+
+ describe("when user selects the matching website", () => {
+ let mockShowWarningMessage: sinon.SinonStub;
+
+ beforeEach(() => {
+ sandbox.stub(vscode.workspace, "workspaceFolders").get(() => [
+ { uri: { fsPath: "/test/workspace" }, name: "workspace", index: 0 }
+ ]);
+ sandbox.stub(WorkspaceInfoFinderUtil, "getWebsiteRecordId").returns("matching-website-id");
+ sandbox.stub(WorkspaceInfoFinderUtil, "findPowerPagesSiteFolder").returns(null);
+ mockShowWarningMessage = sandbox.stub(vscode.window, "showWarningMessage");
+
+ mockPacWrapper.orgList.resolves({
+ Status: SUCCESS,
+ Results: [
+ {
+ FriendlyName: "Test Environment",
+ EnvironmentId: "env-id-1",
+ EnvironmentUrl: "https://test.crm.dynamics.com",
+ OrganizationId: "org-id-1",
+ UniqueName: "testorg",
+ EnvironmentIdentifier: { Id: "env-id-1" }
+ }
+ ]
+ });
+
+ sandbox.stub(ActionsHubUtils, "fetchWebsites").resolves({
+ activeSites: [
+ {
+ name: "Matching Website",
+ websiteRecordId: "matching-website-id",
+ websiteUrl: "https://matching.powerappsportals.com",
+ dataModel: "Enhanced"
+ }
+ ] as unknown as IWebsiteDetails[],
+ inactiveSites: [],
+ otherSites: []
+ });
+
+ // First call returns environment selection, second call returns matching website
+ mockShowQuickPick
+ .onFirstCall().resolves({
+ label: "Test Environment",
+ detail: "https://test.crm.dynamics.com",
+ orgInfo: {
+ OrgId: "org-id-1",
+ UniqueName: "testorg",
+ FriendlyName: "Test Environment",
+ OrgUrl: "https://test.crm.dynamics.com",
+ UserEmail: "",
+ UserId: "",
+ EnvironmentId: "env-id-1"
+ }
+ })
+ .onSecondCall().resolves({
+ label: "Matching Website",
+ detail: "https://matching.powerappsportals.com",
+ description: `${Constants.Strings.ENHANCED_DATA_MODEL} • ${Constants.Strings.MATCHING_SITE_INDICATOR}`,
+ websiteDetails: {
+ name: "Matching Website",
+ websiteRecordId: "matching-website-id",
+ websiteUrl: "https://matching.powerappsportals.com",
+ dataModel: "Enhanced"
+ }
+ });
+ });
+
+ it("should not show confirmation dialog when selected website matches local", async () => {
+ const handler = compareWithEnvironment(mockPacTerminal as unknown as PacTerminal, mockExtensionContext);
+ await handler({ fsPath: "/test/workspace" } as vscode.Uri);
+
+ expect(mockShowWarningMessage.called).to.be.false;
+ });
+ });
+
+ describe("finding website ID from resource path", () => {
+ it("should prioritize resource path over workspace root for finding website ID", async () => {
+ sandbox.stub(vscode.workspace, "workspaceFolders").get(() => [
+ { uri: { fsPath: "/test/workspace" }, name: "workspace", index: 0 }
+ ]);
+
+ const findWebsiteYmlFolderStub = sandbox.stub(WorkspaceInfoFinderUtil, "findWebsiteYmlFolder")
+ .returns("/test/workspace/nested/site-folder");
+
+ const getWebsiteRecordIdStub = sandbox.stub(WorkspaceInfoFinderUtil, "getWebsiteRecordId");
+ getWebsiteRecordIdStub.withArgs("/test/workspace/nested/site-folder").returns("nested-site-id");
+ getWebsiteRecordIdStub.withArgs("/test/workspace").returns("workspace-site-id");
+
+ sandbox.stub(WorkspaceInfoFinderUtil, "findPowerPagesSiteFolder").returns(null);
+
+ mockPacWrapper.orgList.resolves({
+ Status: SUCCESS,
+ Results: []
+ });
+
+ const resourceUri = { fsPath: "/test/workspace/nested/site-folder/file.html" } as vscode.Uri;
+
+ const handler = compareWithEnvironment(mockPacTerminal as unknown as PacTerminal, mockExtensionContext);
+ await handler(resourceUri);
+
+ // findWebsiteYmlFolder should be called with the resource path
+ expect(findWebsiteYmlFolderStub.calledWith(resourceUri.fsPath)).to.be.true;
+ });
+ });
+
+ describe("quick pick icons", () => {
+ beforeEach(() => {
+ sandbox.stub(vscode.workspace, "workspaceFolders").get(() => [
+ { uri: { fsPath: "/test/workspace" }, name: "workspace", index: 0 }
+ ]);
+ sandbox.stub(WorkspaceInfoFinderUtil, "getWebsiteRecordId").returns("test-website-id");
+ sandbox.stub(WorkspaceInfoFinderUtil, "findPowerPagesSiteFolder").returns(null);
+
+ mockPacWrapper.orgList.resolves({
+ Status: SUCCESS,
+ Results: [
+ {
+ FriendlyName: "Test Environment",
+ EnvironmentId: "env-id-1",
+ EnvironmentUrl: "https://test.crm.dynamics.com",
+ OrganizationId: "org-id-1",
+ UniqueName: "testorg",
+ EnvironmentIdentifier: { Id: "env-id-1" }
+ }
+ ]
+ });
+
+ sandbox.stub(ActionsHubUtils, "fetchWebsites").resolves({
+ activeSites: [
+ {
+ name: "Test Website",
+ websiteRecordId: "website-id-1",
+ websiteUrl: "https://test.powerappsportals.com",
+ dataModel: "Enhanced"
+ }
+ ] as unknown as IWebsiteDetails[],
+ inactiveSites: [],
+ otherSites: []
+ });
+ });
+
+ it("should include environment icons in quick pick items", async () => {
+ mockShowQuickPick.resolves(undefined); // Cancel to stop flow early
+
+ const handler = compareWithEnvironment(mockPacTerminal as unknown as PacTerminal, mockExtensionContext);
+ await handler({ fsPath: "/test/workspace" } as vscode.Uri);
+
+ // Verify that showQuickPick was called with items containing iconPath
+ expect(mockShowQuickPick.called).to.be.true;
+ const quickPickItems = mockShowQuickPick.firstCall.args[0];
+ expect(quickPickItems).to.be.an("array");
+ expect(quickPickItems[0]).to.have.property("iconPath");
+ expect(quickPickItems[0].iconPath).to.have.property("light");
+ expect(quickPickItems[0].iconPath).to.have.property("dark");
+ });
+
+ it("should include website icons in quick pick items with separators", async () => {
+ // First call returns environment selection, second call is for websites
+ mockShowQuickPick
+ .onFirstCall().resolves({
+ label: "Test Environment",
+ detail: "https://test.crm.dynamics.com",
+ orgInfo: {
+ OrgId: "org-id-1",
+ UniqueName: "testorg",
+ FriendlyName: "Test Environment",
+ OrgUrl: "https://test.crm.dynamics.com",
+ UserEmail: "",
+ UserId: "",
+ EnvironmentId: "env-id-1"
+ }
+ })
+ .onSecondCall().resolves(undefined); // Cancel to stop flow early
+
+ const handler = compareWithEnvironment(mockPacTerminal as unknown as PacTerminal, mockExtensionContext);
+ await handler({ fsPath: "/test/workspace" } as vscode.Uri);
+
+ // Verify that the second showQuickPick was called with items containing separators and website icons
+ expect(mockShowQuickPick.calledTwice).to.be.true;
+ const websiteQuickPickItems = mockShowQuickPick.secondCall.args[0];
+ expect(websiteQuickPickItems).to.be.an("array");
+
+ // First item should be a separator for "Active Sites"
+ expect(websiteQuickPickItems[0]).to.have.property("kind");
+ expect(websiteQuickPickItems[0].kind).to.equal(vscode.QuickPickItemKind.Separator);
+ expect(websiteQuickPickItems[0].label).to.equal(Constants.Strings.ACTIVE_SITES);
+
+ // Second item should be the actual website with globe icon
+ expect(websiteQuickPickItems[1]).to.have.property("iconPath");
+ expect(websiteQuickPickItems[1].iconPath).to.be.instanceOf(vscode.ThemeIcon);
+ expect((websiteQuickPickItems[1].iconPath as vscode.ThemeIcon).id).to.equal("globe");
+ });
+ });
});
});
diff --git a/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/ExportMetadataDiffHandler.test.ts b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/ExportMetadataDiffHandler.test.ts
new file mode 100644
index 000000000..abf217068
--- /dev/null
+++ b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/ExportMetadataDiffHandler.test.ts
@@ -0,0 +1,188 @@
+/*
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ */
+
+import * as vscode from "vscode";
+import { expect } from "chai";
+import sinon from "sinon";
+import { exportMetadataDiff } from "../../../../../../power-pages/actions-hub/handlers/metadata-diff/ExportMetadataDiffHandler";
+import { MetadataDiffSiteTreeItem } from "../../../../../../power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffSiteTreeItem";
+import { FileComparisonStatus, IFileComparisonResult, ISiteComparisonResults } from "../../../../../../power-pages/actions-hub/models/IFileComparisonResult";
+import * as TelemetryHelper from "../../../../../../power-pages/actions-hub/TelemetryHelper";
+
+describe("ExportMetadataDiffHandler", () => {
+ let sandbox: sinon.SinonSandbox;
+ let showSaveDialogStub: sinon.SinonStub;
+ let showInformationMessageStub: sinon.SinonStub;
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ showSaveDialogStub = sandbox.stub(vscode.window, "showSaveDialog");
+ showInformationMessageStub = sandbox.stub(vscode.window, "showInformationMessage");
+ // Stub telemetry helpers
+ sandbox.stub(TelemetryHelper, "traceInfo");
+ sandbox.stub(TelemetryHelper, "traceError");
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ function createSiteResults(
+ comparisonResults: IFileComparisonResult[],
+ siteName = "Test Site",
+ localSiteName = "Local Test Site",
+ environmentName = "Test Environment",
+ websiteId = "test-website-id",
+ environmentId = "test-environment-id"
+ ): ISiteComparisonResults {
+ return {
+ comparisonResults,
+ siteName,
+ localSiteName,
+ environmentName,
+ websiteId,
+ environmentId
+ };
+ }
+
+ function createMockTreeItem(
+ comparisonResults: IFileComparisonResult[],
+ siteName = "Test Site"
+ ): MetadataDiffSiteTreeItem {
+ return new MetadataDiffSiteTreeItem(createSiteResults(comparisonResults, siteName));
+ }
+
+ describe("exportMetadataDiff", () => {
+ it("should prompt user to save file", async () => {
+ const mockResults: IFileComparisonResult[] = [
+ { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED }
+ ];
+ const treeItem = createMockTreeItem(mockResults);
+
+ showSaveDialogStub.resolves(undefined); // User cancelled
+
+ await exportMetadataDiff(treeItem);
+
+ expect(showSaveDialogStub.calledOnce).to.be.true;
+ expect(showSaveDialogStub.firstCall.args[0]).to.have.property("filters");
+ });
+
+ it("should not show success message when user cancels save dialog", async () => {
+ const mockResults: IFileComparisonResult[] = [
+ { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED }
+ ];
+ const treeItem = createMockTreeItem(mockResults);
+
+ showSaveDialogStub.resolves(undefined);
+
+ await exportMetadataDiff(treeItem);
+
+ expect(showSaveDialogStub.calledOnce).to.be.true;
+ expect(showInformationMessageStub.called).to.be.false;
+ });
+
+ it("should have JSON filter in save dialog", async () => {
+ const mockResults: IFileComparisonResult[] = [
+ { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED }
+ ];
+ const treeItem = createMockTreeItem(mockResults);
+
+ showSaveDialogStub.resolves(undefined);
+
+ await exportMetadataDiff(treeItem);
+
+ const saveDialogOptions = showSaveDialogStub.firstCall.args[0];
+ expect(saveDialogOptions.filters).to.have.property("Metadata Diff JSON");
+ expect(saveDialogOptions.filters["Metadata Diff JSON"]).to.include("json");
+ });
+
+ it("should use site name in default file name", async () => {
+ const mockResults: IFileComparisonResult[] = [
+ { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED }
+ ];
+ const treeItem = createMockTreeItem(mockResults, "My Test Site");
+
+ showSaveDialogStub.resolves(undefined);
+
+ await exportMetadataDiff(treeItem);
+
+ const saveDialogOptions = showSaveDialogStub.firstCall.args[0];
+ expect(saveDialogOptions.defaultUri.fsPath).to.include("My_Test_Site");
+ });
+
+ it("should sanitize special characters from site name in default file name", async () => {
+ const mockResults: IFileComparisonResult[] = [
+ { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED }
+ ];
+ const treeItem = createMockTreeItem(mockResults, "Site/With:Special*Chars");
+
+ showSaveDialogStub.resolves(undefined);
+
+ await exportMetadataDiff(treeItem);
+
+ const saveDialogOptions = showSaveDialogStub.firstCall.args[0];
+ // Special characters should be replaced with underscores
+ expect(saveDialogOptions.defaultUri.fsPath).to.include("Site_With_Special_Chars");
+ });
+
+ it("should include -diff- in default file name", async () => {
+ const mockResults: IFileComparisonResult[] = [
+ { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED }
+ ];
+ const treeItem = createMockTreeItem(mockResults);
+
+ showSaveDialogStub.resolves(undefined);
+
+ await exportMetadataDiff(treeItem);
+
+ const saveDialogOptions = showSaveDialogStub.firstCall.args[0];
+ expect(saveDialogOptions.defaultUri.fsPath).to.include("-diff-");
+ });
+
+ it("should include .json extension in default file name", async () => {
+ const mockResults: IFileComparisonResult[] = [
+ { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED }
+ ];
+ const treeItem = createMockTreeItem(mockResults);
+
+ showSaveDialogStub.resolves(undefined);
+
+ await exportMetadataDiff(treeItem);
+
+ const saveDialogOptions = showSaveDialogStub.firstCall.args[0];
+ expect(saveDialogOptions.defaultUri.fsPath).to.include(".json");
+ });
+
+ it("should log telemetry on export start", async () => {
+ const mockResults: IFileComparisonResult[] = [
+ { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED }
+ ];
+ const treeItem = createMockTreeItem(mockResults);
+ const traceInfoStub = TelemetryHelper.traceInfo as sinon.SinonStub;
+
+ showSaveDialogStub.resolves(undefined);
+
+ await exportMetadataDiff(treeItem);
+
+ expect(traceInfoStub.called).to.be.true;
+ expect(traceInfoStub.firstCall.args[0]).to.equal("ActionsHubMetadataDiffExportCalled");
+ });
+
+ it("should include file count in telemetry", async () => {
+ const mockResults: IFileComparisonResult[] = [
+ { localPath: "/local/file1.html", remotePath: "/remote/file1.html", relativePath: "file1.html", status: FileComparisonStatus.MODIFIED },
+ { localPath: "/local/file2.html", remotePath: "/remote/file2.html", relativePath: "file2.html", status: FileComparisonStatus.ADDED }
+ ];
+ const treeItem = createMockTreeItem(mockResults);
+ const traceInfoStub = TelemetryHelper.traceInfo as sinon.SinonStub;
+
+ showSaveDialogStub.resolves(undefined);
+
+ await exportMetadataDiff(treeItem);
+
+ expect(traceInfoStub.firstCall.args[1]).to.have.property("fileCount", "2");
+ });
+ });
+});
diff --git a/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/GenerateHtmlReportHandler.test.ts b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/GenerateHtmlReportHandler.test.ts
new file mode 100644
index 000000000..93fc03910
--- /dev/null
+++ b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/GenerateHtmlReportHandler.test.ts
@@ -0,0 +1,190 @@
+/*
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ */
+
+import * as vscode from "vscode";
+import { expect } from "chai";
+import sinon from "sinon";
+import { generateHtmlReport } from "../../../../../../power-pages/actions-hub/handlers/metadata-diff/GenerateHtmlReportHandler";
+import { MetadataDiffSiteTreeItem } from "../../../../../../power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffSiteTreeItem";
+import { FileComparisonStatus, IFileComparisonResult, ISiteComparisonResults } from "../../../../../../power-pages/actions-hub/models/IFileComparisonResult";
+import * as TelemetryHelper from "../../../../../../power-pages/actions-hub/TelemetryHelper";
+
+describe("GenerateHtmlReportHandler", () => {
+ let sandbox: sinon.SinonSandbox;
+ let showSaveDialogStub: sinon.SinonStub;
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ showSaveDialogStub = sandbox.stub(vscode.window, "showSaveDialog");
+ // Stub other methods to prevent actual calls during tests
+ sandbox.stub(vscode.window, "showInformationMessage");
+ sandbox.stub(vscode.window, "showErrorMessage");
+ sandbox.stub(vscode.env, "openExternal");
+ // Stub telemetry helpers
+ sandbox.stub(TelemetryHelper, "traceInfo");
+ sandbox.stub(TelemetryHelper, "traceError");
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ function createSiteResults(comparisonResults: IFileComparisonResult[], siteName = "Test Site", localSiteName = "Local Test Site", environmentName = "Test Environment"): ISiteComparisonResults {
+ return {
+ comparisonResults,
+ siteName,
+ localSiteName,
+ environmentName,
+ websiteId: "test-website-id",
+ environmentId: "test-environment-id"
+ };
+ }
+
+ function createMockTreeItem(comparisonResults: IFileComparisonResult[], siteName = "Test Site", localSiteName = "Local Test Site", environmentName = "Test Environment"): MetadataDiffSiteTreeItem {
+ return new MetadataDiffSiteTreeItem(createSiteResults(comparisonResults, siteName, localSiteName, environmentName));
+ }
+
+ describe("generateHtmlReport", () => {
+ it("should prompt user to save file", async () => {
+ const mockResults: IFileComparisonResult[] = [
+ { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED }
+ ];
+ const treeItem = createMockTreeItem(mockResults);
+
+ showSaveDialogStub.resolves(undefined); // User cancelled
+
+ await generateHtmlReport(treeItem);
+
+ expect(showSaveDialogStub.calledOnce).to.be.true;
+ expect(showSaveDialogStub.firstCall.args[0]).to.have.property("filters");
+ });
+
+ it("should not show success message when user cancels save dialog", async () => {
+ const mockResults: IFileComparisonResult[] = [
+ { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED }
+ ];
+ const treeItem = createMockTreeItem(mockResults);
+
+ showSaveDialogStub.resolves(undefined);
+
+ // When user cancels, the function should return early without showing success message
+ await generateHtmlReport(treeItem);
+
+ // No error should be thrown and the function should complete
+ expect(showSaveDialogStub.calledOnce).to.be.true;
+ });
+
+ it("should have HTML filter in save dialog", async () => {
+ const mockResults: IFileComparisonResult[] = [
+ { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED }
+ ];
+ const treeItem = createMockTreeItem(mockResults);
+
+ showSaveDialogStub.resolves(undefined);
+
+ await generateHtmlReport(treeItem);
+
+ const saveDialogOptions = showSaveDialogStub.firstCall.args[0];
+ expect(saveDialogOptions.filters).to.have.property("HTML Files");
+ expect(saveDialogOptions.filters["HTML Files"]).to.include("html");
+ });
+
+ it("should use site name in default file name", async () => {
+ const mockResults: IFileComparisonResult[] = [
+ { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED }
+ ];
+ const treeItem = createMockTreeItem(mockResults, "My Test Site");
+
+ showSaveDialogStub.resolves(undefined);
+
+ await generateHtmlReport(treeItem);
+
+ const saveDialogOptions = showSaveDialogStub.firstCall.args[0];
+ expect(saveDialogOptions.defaultUri.fsPath).to.include("My-Test-Site");
+ });
+
+ it("should sanitize special characters from site name in default file name", async () => {
+ const mockResults: IFileComparisonResult[] = [
+ { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED }
+ ];
+ const treeItem = createMockTreeItem(mockResults, "Site/With:Special*Chars");
+
+ showSaveDialogStub.resolves(undefined);
+
+ await generateHtmlReport(treeItem);
+
+ const saveDialogOptions = showSaveDialogStub.firstCall.args[0];
+ // Special characters should be replaced with hyphens
+ expect(saveDialogOptions.defaultUri.fsPath).to.include("Site-With-Special-Chars");
+ });
+
+ it("should include metadata-diff-report prefix in default file name", async () => {
+ const mockResults: IFileComparisonResult[] = [
+ { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED }
+ ];
+ const treeItem = createMockTreeItem(mockResults);
+
+ showSaveDialogStub.resolves(undefined);
+
+ await generateHtmlReport(treeItem);
+
+ const saveDialogOptions = showSaveDialogStub.firstCall.args[0];
+ expect(saveDialogOptions.defaultUri.fsPath).to.include("metadata-diff-report");
+ });
+ });
+
+ describe("telemetry", () => {
+ it("should call traceInfo when generateHtmlReport is invoked", async () => {
+ const mockResults: IFileComparisonResult[] = [
+ { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED }
+ ];
+ const treeItem = createMockTreeItem(mockResults, "Telemetry Test Site");
+
+ showSaveDialogStub.resolves(undefined);
+
+ await generateHtmlReport(treeItem);
+
+ const traceInfoStub = TelemetryHelper.traceInfo as sinon.SinonStub;
+ expect(traceInfoStub.called).to.be.true;
+ expect(traceInfoStub.firstCall.args[0]).to.equal("ActionsHubMetadataDiffGenerateHtmlReportCalled");
+ expect(traceInfoStub.firstCall.args[1]).to.deep.include({
+ siteName: "Telemetry Test Site",
+ fileCount: 1
+ });
+ });
+
+ it("should include correct file count in telemetry for multiple files", async () => {
+ const mockResults: IFileComparisonResult[] = [
+ { localPath: "/local/file1.html", remotePath: "/remote/file1.html", relativePath: "file1.html", status: FileComparisonStatus.MODIFIED },
+ { localPath: "/local/file2.html", remotePath: "/remote/file2.html", relativePath: "file2.html", status: FileComparisonStatus.ADDED },
+ { localPath: "/local/file3.html", remotePath: "/remote/file3.html", relativePath: "file3.html", status: FileComparisonStatus.DELETED }
+ ];
+ const treeItem = createMockTreeItem(mockResults);
+
+ showSaveDialogStub.resolves(undefined);
+
+ await generateHtmlReport(treeItem);
+
+ const traceInfoStub = TelemetryHelper.traceInfo as sinon.SinonStub;
+ expect(traceInfoStub.firstCall.args[1]).to.deep.include({
+ fileCount: 3
+ });
+ });
+
+ it("should not call traceError when user cancels save dialog", async () => {
+ const mockResults: IFileComparisonResult[] = [
+ { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED }
+ ];
+ const treeItem = createMockTreeItem(mockResults);
+
+ showSaveDialogStub.resolves(undefined);
+
+ await generateHtmlReport(treeItem);
+
+ const traceErrorStub = TelemetryHelper.traceError as sinon.SinonStub;
+ expect(traceErrorStub.called).to.be.false;
+ });
+ });
+});
diff --git a/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler.test.ts b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler.test.ts
new file mode 100644
index 000000000..2069950c0
--- /dev/null
+++ b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler.test.ts
@@ -0,0 +1,107 @@
+/*
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ */
+
+import * as vscode from "vscode";
+import { expect } from "chai";
+import sinon from "sinon";
+import * as TelemetryHelper from "../../../../../../power-pages/actions-hub/TelemetryHelper";
+import MetadataDiffContext from "../../../../../../power-pages/actions-hub/MetadataDiffContext";
+
+describe("ImportMetadataDiffHandler", () => {
+ let sandbox: sinon.SinonSandbox;
+ let showOpenDialogStub: sinon.SinonStub;
+ let showInformationMessageStub: sinon.SinonStub;
+ let showErrorMessageStub: sinon.SinonStub;
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ showOpenDialogStub = sandbox.stub(vscode.window, "showOpenDialog");
+ showInformationMessageStub = sandbox.stub(vscode.window, "showInformationMessage");
+ showErrorMessageStub = sandbox.stub(vscode.window, "showErrorMessage");
+ sandbox.stub(vscode.window, "showWarningMessage");
+ // Stub telemetry helpers
+ sandbox.stub(TelemetryHelper, "traceInfo");
+ sandbox.stub(TelemetryHelper, "traceError");
+ // Clear context before each test
+ MetadataDiffContext.clear();
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ MetadataDiffContext.clear();
+ });
+
+ describe("importMetadataDiff", () => {
+ it("should prompt user to select file", async () => {
+ showOpenDialogStub.resolves(undefined); // User cancelled
+
+ // Import the module dynamically to ensure stubs are in place
+ const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler");
+
+ await importMetadataDiff();
+
+ expect(showOpenDialogStub.calledOnce).to.be.true;
+ expect(showOpenDialogStub.firstCall.args[0]).to.have.property("filters");
+ });
+
+ it("should not proceed when user cancels file selection", async () => {
+ showOpenDialogStub.resolves(undefined);
+
+ const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler");
+
+ await importMetadataDiff();
+
+ expect(showOpenDialogStub.calledOnce).to.be.true;
+ expect(showInformationMessageStub.called).to.be.false;
+ expect(showErrorMessageStub.called).to.be.false;
+ });
+
+ it("should not proceed when user selects empty array", async () => {
+ showOpenDialogStub.resolves([]);
+
+ const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler");
+
+ await importMetadataDiff();
+
+ expect(showOpenDialogStub.calledOnce).to.be.true;
+ expect(showInformationMessageStub.called).to.be.false;
+ });
+
+ it("should have JSON filter in open dialog", async () => {
+ showOpenDialogStub.resolves(undefined);
+
+ const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler");
+
+ await importMetadataDiff();
+
+ const openDialogOptions = showOpenDialogStub.firstCall.args[0];
+ expect(openDialogOptions.filters).to.have.property("Metadata Diff JSON");
+ expect(openDialogOptions.filters["Metadata Diff JSON"]).to.include("json");
+ });
+
+ it("should not allow multiple file selection", async () => {
+ showOpenDialogStub.resolves(undefined);
+
+ const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler");
+
+ await importMetadataDiff();
+
+ const openDialogOptions = showOpenDialogStub.firstCall.args[0];
+ expect(openDialogOptions.canSelectMany).to.be.false;
+ });
+
+ it("should log telemetry on import start", async () => {
+ const traceInfoStub = TelemetryHelper.traceInfo as sinon.SinonStub;
+ showOpenDialogStub.resolves(undefined);
+
+ const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler");
+
+ await importMetadataDiff();
+
+ expect(traceInfoStub.called).to.be.true;
+ expect(traceInfoStub.firstCall.args[0]).to.equal("ActionsHubMetadataDiffImportCalled");
+ });
+ });
+});
diff --git a/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/OpenAllMetadataDiffsHandler.test.ts b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/OpenAllMetadataDiffsHandler.test.ts
index df99795e0..7125e4a54 100644
--- a/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/OpenAllMetadataDiffsHandler.test.ts
+++ b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/OpenAllMetadataDiffsHandler.test.ts
@@ -6,10 +6,25 @@
import { expect } from "chai";
import * as sinon from "sinon";
import * as vscode from "vscode";
-import { openAllMetadataDiffs, isBinaryFile } from "../../../../../../power-pages/actions-hub/handlers/metadata-diff/OpenAllMetadataDiffsHandler";
+import { openAllMetadataDiffs } from "../../../../../../power-pages/actions-hub/handlers/metadata-diff/OpenAllMetadataDiffsHandler";
+import { isBinaryFile } from "../../../../../../power-pages/actions-hub/ActionsHubUtils";
import { MetadataDiffSiteTreeItem } from "../../../../../../power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffSiteTreeItem";
import * as TelemetryHelper from "../../../../../../power-pages/actions-hub/TelemetryHelper";
-import { IFileComparisonResult } from "../../../../../../power-pages/actions-hub/models/IFileComparisonResult";
+import { IFileComparisonResult, ISiteComparisonResults } from "../../../../../../power-pages/actions-hub/models/IFileComparisonResult";
+
+/**
+ * Helper function to create ISiteComparisonResults for testing
+ */
+function createSiteResults(comparisonResults: IFileComparisonResult[], siteName = "Test Site", localSiteName = "Local Test Site", environmentName = "Test Environment"): ISiteComparisonResults {
+ return {
+ comparisonResults,
+ siteName,
+ localSiteName,
+ environmentName,
+ websiteId: "test-website-id",
+ environmentId: "test-environment-id"
+ };
+}
describe("OpenAllMetadataDiffsHandler", () => {
let sandbox: sinon.SinonSandbox;
@@ -99,7 +114,7 @@ describe("OpenAllMetadataDiffsHandler", () => {
status: "added"
}
];
- const siteItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment");
+ const siteItem = new MetadataDiffSiteTreeItem(createSiteResults(results));
await openAllMetadataDiffs(siteItem);
@@ -112,7 +127,7 @@ describe("OpenAllMetadataDiffsHandler", () => {
});
it("should not execute command when no results", async () => {
- const siteItem = new MetadataDiffSiteTreeItem([], "Test Site", "Test Environment");
+ const siteItem = new MetadataDiffSiteTreeItem(createSiteResults([]));
await openAllMetadataDiffs(siteItem);
@@ -128,7 +143,7 @@ describe("OpenAllMetadataDiffsHandler", () => {
status: "modified"
}
];
- const siteItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment");
+ const siteItem = new MetadataDiffSiteTreeItem(createSiteResults(results));
await openAllMetadataDiffs(siteItem);
@@ -153,7 +168,7 @@ describe("OpenAllMetadataDiffsHandler", () => {
status: "added"
}
];
- const siteItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment");
+ const siteItem = new MetadataDiffSiteTreeItem(createSiteResults(results));
await openAllMetadataDiffs(siteItem);
@@ -172,7 +187,7 @@ describe("OpenAllMetadataDiffsHandler", () => {
status: "deleted"
}
];
- const siteItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment");
+ const siteItem = new MetadataDiffSiteTreeItem(createSiteResults(results));
await openAllMetadataDiffs(siteItem);
@@ -203,7 +218,7 @@ describe("OpenAllMetadataDiffsHandler", () => {
status: "deleted"
}
];
- const siteItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment");
+ const siteItem = new MetadataDiffSiteTreeItem(createSiteResults(results));
await openAllMetadataDiffs(siteItem);
@@ -238,7 +253,7 @@ describe("OpenAllMetadataDiffsHandler", () => {
status: "modified"
}
];
- const siteItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment");
+ const siteItem = new MetadataDiffSiteTreeItem(createSiteResults(results));
await openAllMetadataDiffs(siteItem);
@@ -266,7 +281,7 @@ describe("OpenAllMetadataDiffsHandler", () => {
status: "added"
}
];
- const siteItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment");
+ const siteItem = new MetadataDiffSiteTreeItem(createSiteResults(results));
await openAllMetadataDiffs(siteItem);
@@ -293,7 +308,7 @@ describe("OpenAllMetadataDiffsHandler", () => {
status: "modified"
}
];
- const siteItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment");
+ const siteItem = new MetadataDiffSiteTreeItem(createSiteResults(results));
await openAllMetadataDiffs(siteItem);
diff --git a/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/OpenMetadataDiffFileHandler.test.ts b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/OpenMetadataDiffFileHandler.test.ts
index 0e2796c48..ea0aa3581 100644
--- a/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/OpenMetadataDiffFileHandler.test.ts
+++ b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/OpenMetadataDiffFileHandler.test.ts
@@ -14,10 +14,12 @@ import { IFileComparisonResult } from "../../../../../../power-pages/actions-hub
describe("OpenMetadataDiffFileHandler", () => {
let sandbox: sinon.SinonSandbox;
let executeCommandStub: sinon.SinonStub;
+ let showInformationMessageStub: sinon.SinonStub;
beforeEach(() => {
sandbox = sinon.createSandbox();
executeCommandStub = sandbox.stub(vscode.commands, "executeCommand");
+ showInformationMessageStub = sandbox.stub(vscode.window, "showInformationMessage");
sandbox.stub(TelemetryHelper, "traceInfo");
sandbox.stub(vscode.env, "sessionId").get(() => "test-session-id");
});
@@ -45,7 +47,8 @@ describe("OpenMetadataDiffFileHandler", () => {
expect(traceInfoStub.firstCall.args[1]).to.deep.equal({
methodName: "openMetadataDiffFile",
relativePath: "folder/file.txt",
- status: "modified"
+ status: "modified",
+ isImported: false
});
});
@@ -218,5 +221,54 @@ describe("OpenMetadataDiffFileHandler", () => {
expect(diffCall, `Expected no vscode.diff call for ${ext} file`).to.be.undefined;
}
});
+
+ it("should include isImported in telemetry for imported comparisons", async () => {
+ const traceInfoStub = TelemetryHelper.traceInfo as sinon.SinonStub;
+
+ const comparisonResult: IFileComparisonResult = {
+ localPath: "/local/file.txt",
+ remotePath: "/remote/file.txt",
+ relativePath: "folder/file.txt",
+ status: "modified"
+ };
+ const fileItem = new MetadataDiffFileTreeItem(comparisonResult, "Test Site", true);
+
+ await openMetadataDiffFile(fileItem);
+
+ expect(traceInfoStub.calledOnce).to.be.true;
+ expect(traceInfoStub.firstCall.args[1]).to.have.property("isImported", true);
+ });
+
+ it("should show information message for binary files in imported comparisons", async () => {
+ const comparisonResult: IFileComparisonResult = {
+ localPath: "/local/image.png",
+ remotePath: "/remote/image.png",
+ relativePath: "image.png",
+ status: "modified"
+ };
+ const fileItem = new MetadataDiffFileTreeItem(comparisonResult, "Test Site", true);
+
+ await openMetadataDiffFile(fileItem);
+
+ expect(showInformationMessageStub.calledOnce).to.be.true;
+ });
+
+ it("should not open binary file for imported comparisons", async () => {
+ const comparisonResult: IFileComparisonResult = {
+ localPath: "/local/image.png",
+ remotePath: "/remote/image.png",
+ relativePath: "image.png",
+ status: "modified"
+ };
+ const fileItem = new MetadataDiffFileTreeItem(comparisonResult, "Test Site", true);
+
+ await openMetadataDiffFile(fileItem);
+
+ // Should not call vscode.open or vscode.diff for imported binary files
+ const openCall = executeCommandStub.getCalls().find(
+ call => call.args[0] === "vscode.open" || call.args[0] === "vscode.diff"
+ );
+ expect(openCall).to.be.undefined;
+ });
});
});
diff --git a/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/ResyncMetadataDiffHandler.test.ts b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/ResyncMetadataDiffHandler.test.ts
new file mode 100644
index 000000000..821b8aca2
--- /dev/null
+++ b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/ResyncMetadataDiffHandler.test.ts
@@ -0,0 +1,363 @@
+/*
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ */
+
+import { expect } from "chai";
+import * as sinon from "sinon";
+import * as vscode from "vscode";
+import { resyncMetadataDiff } from "../../../../../../power-pages/actions-hub/handlers/metadata-diff/ResyncMetadataDiffHandler";
+import { Constants } from "../../../../../../power-pages/actions-hub/Constants";
+import { PacTerminal } from "../../../../../../lib/PacTerminal";
+import { MetadataDiffSiteTreeItem } from "../../../../../../power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffSiteTreeItem";
+import { ISiteComparisonResults, FileComparisonStatus } from "../../../../../../power-pages/actions-hub/models/IFileComparisonResult";
+import MetadataDiffContext from "../../../../../../power-pages/actions-hub/MetadataDiffContext";
+import * as TelemetryHelper from "../../../../../../power-pages/actions-hub/TelemetryHelper";
+import * as WorkspaceInfoFinderUtil from "../../../../../../../common/utilities/WorkspaceInfoFinderUtil";
+import * as MetadataDiffUtils from "../../../../../../power-pages/actions-hub/handlers/metadata-diff/MetadataDiffUtils";
+
+describe("ResyncMetadataDiffHandler", () => {
+ let sandbox: sinon.SinonSandbox;
+ let mockShowErrorMessage: sinon.SinonStub;
+ let mockShowWarningMessage: sinon.SinonStub;
+ let traceInfoStub: sinon.SinonStub;
+ let mockPacTerminal: sinon.SinonStubbedInstance;
+ let mockExtensionContext: vscode.ExtensionContext;
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ mockShowErrorMessage = sandbox.stub(vscode.window, "showErrorMessage");
+ mockShowWarningMessage = sandbox.stub(vscode.window, "showWarningMessage");
+ sandbox.stub(vscode.window, "showInformationMessage");
+ traceInfoStub = sandbox.stub(TelemetryHelper, "traceInfo");
+ sandbox.stub(TelemetryHelper, "traceError");
+ sandbox.stub(TelemetryHelper, "getBaseEventInfo").returns({ foo: "bar" });
+ sandbox.stub(vscode.env, "sessionId").get(() => "test-session-id");
+
+ // Mock PacTerminal
+ mockPacTerminal = sandbox.createStubInstance(PacTerminal);
+
+ // Mock ExtensionContext
+ mockExtensionContext = {
+ storageUri: { fsPath: "/test/storage/path" }
+ } as unknown as vscode.ExtensionContext;
+
+ // Clear MetadataDiffContext before each test
+ MetadataDiffContext.clear();
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ MetadataDiffContext.clear();
+ });
+
+ function createMockSiteComparisonResults(overrides: Partial = {}): ISiteComparisonResults {
+ return {
+ siteName: "Test Site",
+ localSiteName: "Local Site",
+ environmentName: "Test Environment",
+ websiteId: "test-website-id",
+ environmentId: "test-environment-id",
+ comparisonResults: [
+ {
+ localPath: "/local/path/file.html",
+ remotePath: "/remote/path/file.html",
+ relativePath: "file.html",
+ status: FileComparisonStatus.MODIFIED
+ }
+ ],
+ isImported: false,
+ ...overrides
+ };
+ }
+
+ function createMockSiteTreeItem(overrides: Partial = {}): MetadataDiffSiteTreeItem {
+ const siteResults = createMockSiteComparisonResults(overrides);
+ return new MetadataDiffSiteTreeItem(siteResults);
+ }
+
+ describe("resyncMetadataDiff", () => {
+ describe("when site is imported", () => {
+ it("should show warning message and not proceed", async () => {
+ const siteItem = createMockSiteTreeItem({ isImported: true });
+
+ const handler = resyncMetadataDiff(mockPacTerminal as unknown as PacTerminal, mockExtensionContext);
+ await handler(siteItem);
+
+ expect(mockShowWarningMessage.calledOnce).to.be.true;
+ expect(mockShowWarningMessage.firstCall.args[0]).to.equal(Constants.Strings.METADATA_DIFF_CANNOT_RESYNC_IMPORTED);
+ });
+
+ it("should log telemetry event", async () => {
+ const siteItem = createMockSiteTreeItem({ isImported: true });
+
+ const handler = resyncMetadataDiff(mockPacTerminal as unknown as PacTerminal, mockExtensionContext);
+ await handler(siteItem);
+
+ expect(traceInfoStub.calledWith(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_RESYNC_CALLED)).to.be.true;
+ const callArgs = traceInfoStub.getCalls().find(
+ call => call.args[0] === Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_RESYNC_CALLED
+ )?.args[1];
+ expect(callArgs).to.deep.include({
+ methodName: "resyncMetadataDiff",
+ isImported: true
+ });
+ });
+ });
+
+ describe("when no workspace folders exist", () => {
+ beforeEach(() => {
+ sandbox.stub(vscode.workspace, "workspaceFolders").get(() => undefined);
+ });
+
+ it("should show error message", async () => {
+ const siteItem = createMockSiteTreeItem();
+
+ const handler = resyncMetadataDiff(mockPacTerminal as unknown as PacTerminal, mockExtensionContext);
+ await handler(siteItem);
+
+ expect(mockShowErrorMessage.calledOnce).to.be.true;
+ expect(mockShowErrorMessage.firstCall.args[0]).to.equal(Constants.Strings.NO_WORKSPACE_FOLDER_OPEN);
+ });
+
+ it("should log telemetry event", async () => {
+ const siteItem = createMockSiteTreeItem();
+
+ const handler = resyncMetadataDiff(mockPacTerminal as unknown as PacTerminal, mockExtensionContext);
+ await handler(siteItem);
+
+ expect(traceInfoStub.calledWith(Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_LOCAL_NO_WORKSPACE)).to.be.true;
+ });
+ });
+
+ describe("when workspace folders are empty", () => {
+ beforeEach(() => {
+ sandbox.stub(vscode.workspace, "workspaceFolders").get(() => []);
+ });
+
+ it("should show error message", async () => {
+ const siteItem = createMockSiteTreeItem();
+
+ const handler = resyncMetadataDiff(mockPacTerminal as unknown as PacTerminal, mockExtensionContext);
+ await handler(siteItem);
+
+ expect(mockShowErrorMessage.calledOnce).to.be.true;
+ expect(mockShowErrorMessage.firstCall.args[0]).to.equal(Constants.Strings.NO_WORKSPACE_FOLDER_OPEN);
+ });
+ });
+
+ describe("when website ID is not found", () => {
+ beforeEach(() => {
+ sandbox.stub(vscode.workspace, "workspaceFolders").get(() => [
+ { uri: { fsPath: "/test/workspace" }, name: "workspace", index: 0 }
+ ]);
+ sandbox.stub(WorkspaceInfoFinderUtil, "getWebsiteRecordId").returns(undefined as unknown as string);
+ sandbox.stub(WorkspaceInfoFinderUtil, "findPowerPagesSiteFolder").returns(undefined as unknown as string);
+ });
+
+ it("should show error message", async () => {
+ const siteItem = createMockSiteTreeItem();
+
+ const handler = resyncMetadataDiff(mockPacTerminal as unknown as PacTerminal, mockExtensionContext);
+ await handler(siteItem);
+
+ expect(mockShowErrorMessage.calledOnce).to.be.true;
+ expect(mockShowErrorMessage.firstCall.args[0]).to.equal(Constants.Strings.WEBSITE_ID_NOT_FOUND);
+ });
+
+ it("should log telemetry event", async () => {
+ const siteItem = createMockSiteTreeItem();
+
+ const handler = resyncMetadataDiff(mockPacTerminal as unknown as PacTerminal, mockExtensionContext);
+ await handler(siteItem);
+
+ expect(traceInfoStub.calledWith(Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_LOCAL_WEBSITE_ID_NOT_FOUND)).to.be.true;
+ });
+ });
+
+ describe("when storage path is not available", () => {
+ beforeEach(() => {
+ sandbox.stub(vscode.workspace, "workspaceFolders").get(() => [
+ { uri: { fsPath: "/test/workspace" }, name: "workspace", index: 0 }
+ ]);
+ sandbox.stub(WorkspaceInfoFinderUtil, "getWebsiteRecordId").returns("test-site-id");
+ });
+
+ it("should return early without error", async () => {
+ const contextWithoutStorage = {
+ storageUri: undefined
+ } as unknown as vscode.ExtensionContext;
+
+ const siteItem = createMockSiteTreeItem();
+
+ const handler = resyncMetadataDiff(mockPacTerminal as unknown as PacTerminal, contextWithoutStorage);
+ await handler(siteItem);
+
+ expect(mockShowErrorMessage.called).to.be.false;
+ });
+ });
+
+ describe("telemetry", () => {
+ it("should log initial telemetry event when handler is called", async () => {
+ sandbox.stub(vscode.workspace, "workspaceFolders").get(() => undefined);
+
+ const siteItem = createMockSiteTreeItem({
+ websiteId: "my-site-id",
+ environmentId: "my-env-id",
+ siteName: "My Site",
+ environmentName: "My Environment"
+ });
+
+ const handler = resyncMetadataDiff(mockPacTerminal as unknown as PacTerminal, mockExtensionContext);
+ await handler(siteItem);
+
+ expect(traceInfoStub.calledWith(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_RESYNC_CALLED)).to.be.true;
+ const callArgs = traceInfoStub.getCalls().find(
+ call => call.args[0] === Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_RESYNC_CALLED
+ )?.args[1];
+ expect(callArgs).to.deep.include({
+ methodName: "resyncMetadataDiff",
+ siteName: "My Site",
+ environmentName: "My Environment",
+ websiteId: "my-site-id",
+ environmentId: "my-env-id",
+ isImported: false
+ });
+ });
+ });
+
+ describe("when resync completes with no differences", () => {
+ let mockPacWrapper: {
+ downloadSiteWithProgress: sinon.SinonStub;
+ };
+ let clearSiteByKeyStub: sinon.SinonStub;
+ let processComparisonResultsStub: sinon.SinonStub;
+
+ beforeEach(() => {
+ sandbox.stub(vscode.workspace, "workspaceFolders").get(() => [
+ { uri: { fsPath: "/test/workspace" }, name: "workspace", index: 0 }
+ ]);
+ sandbox.stub(WorkspaceInfoFinderUtil, "getWebsiteRecordId").returns("test-website-id");
+ sandbox.stub(WorkspaceInfoFinderUtil, "findPowerPagesSiteFolder").returns(null);
+
+ // Stub prepareSiteStoragePath to return a test path
+ sandbox.stub(MetadataDiffUtils, "prepareSiteStoragePath").returns("/test/storage/sites-for-comparison/test-website-id");
+
+ mockPacWrapper = {
+ downloadSiteWithProgress: sandbox.stub().resolves(true)
+ };
+ mockPacTerminal.getWrapper.returns(mockPacWrapper as unknown as ReturnType);
+
+ // Stub processComparisonResults to return false (no differences)
+ processComparisonResultsStub = sandbox.stub(MetadataDiffUtils, "processComparisonResults").resolves(false);
+
+ // Set up initial comparison results
+ MetadataDiffContext.setResults(
+ [{
+ localPath: "/local/path/file.html",
+ remotePath: "/remote/path/file.html",
+ relativePath: "file.html",
+ status: FileComparisonStatus.MODIFIED
+ }],
+ "Test Site",
+ "Local Site",
+ "Test Environment",
+ "test-website-id",
+ "test-environment-id",
+ false
+ );
+
+ clearSiteByKeyStub = sandbox.stub(MetadataDiffContext, "clearSiteByKey");
+ });
+
+ it("should clear the site from MetadataDiffContext when no differences found", async () => {
+ const siteItem = createMockSiteTreeItem();
+
+ const handler = resyncMetadataDiff(mockPacTerminal as unknown as PacTerminal, mockExtensionContext);
+ await handler(siteItem);
+
+ expect(clearSiteByKeyStub.calledOnce).to.be.true;
+ expect(clearSiteByKeyStub.firstCall.args[0]).to.equal("test-website-id");
+ expect(clearSiteByKeyStub.firstCall.args[1]).to.equal("test-environment-id");
+ expect(clearSiteByKeyStub.firstCall.args[2]).to.equal(false);
+ });
+
+ it("should call processComparisonResults with correct parameters", async () => {
+ const siteItem = createMockSiteTreeItem({
+ dataModelVersion: 2
+ });
+
+ const handler = resyncMetadataDiff(mockPacTerminal as unknown as PacTerminal, mockExtensionContext);
+ await handler(siteItem);
+
+ expect(processComparisonResultsStub.calledOnce).to.be.true;
+ const callArgs = processComparisonResultsStub.firstCall.args;
+ expect(callArgs[2]).to.equal("Test Site"); // siteName
+ expect(callArgs[3]).to.equal("Local Site"); // localSiteName
+ expect(callArgs[4]).to.equal("Test Environment"); // environmentName
+ expect(callArgs[11]).to.equal(2); // dataModelVersion
+ });
+ });
+
+ describe("when resync completes with differences", () => {
+ let mockPacWrapper: {
+ downloadSiteWithProgress: sinon.SinonStub;
+ };
+ let clearSiteByKeyStub: sinon.SinonStub;
+ let mockShowInformationMessage: sinon.SinonStub;
+
+ beforeEach(() => {
+ sandbox.restore(); // Restore to avoid conflicts
+
+ sandbox = sinon.createSandbox();
+ mockShowErrorMessage = sandbox.stub(vscode.window, "showErrorMessage");
+ mockShowWarningMessage = sandbox.stub(vscode.window, "showWarningMessage");
+ mockShowInformationMessage = sandbox.stub(vscode.window, "showInformationMessage");
+ traceInfoStub = sandbox.stub(TelemetryHelper, "traceInfo");
+ sandbox.stub(TelemetryHelper, "traceError");
+ sandbox.stub(TelemetryHelper, "getBaseEventInfo").returns({ foo: "bar" });
+ sandbox.stub(vscode.env, "sessionId").get(() => "test-session-id");
+
+ sandbox.stub(vscode.workspace, "workspaceFolders").get(() => [
+ { uri: { fsPath: "/test/workspace" }, name: "workspace", index: 0 }
+ ]);
+ sandbox.stub(WorkspaceInfoFinderUtil, "getWebsiteRecordId").returns("test-website-id");
+ sandbox.stub(WorkspaceInfoFinderUtil, "findPowerPagesSiteFolder").returns(null);
+
+ // Stub prepareSiteStoragePath to return a test path
+ sandbox.stub(MetadataDiffUtils, "prepareSiteStoragePath").returns("/test/storage/sites-for-comparison/test-website-id");
+
+ mockPacWrapper = {
+ downloadSiteWithProgress: sandbox.stub().resolves(true)
+ };
+
+ mockPacTerminal = sandbox.createStubInstance(PacTerminal);
+ mockPacTerminal.getWrapper.returns(mockPacWrapper as unknown as ReturnType);
+
+ // Stub processComparisonResults to return true (has differences)
+ sandbox.stub(MetadataDiffUtils, "processComparisonResults").resolves(true);
+
+ clearSiteByKeyStub = sandbox.stub(MetadataDiffContext, "clearSiteByKey");
+ });
+
+ it("should not clear the site from MetadataDiffContext when differences are found", async () => {
+ const siteItem = createMockSiteTreeItem();
+
+ const handler = resyncMetadataDiff(mockPacTerminal as unknown as PacTerminal, mockExtensionContext);
+ await handler(siteItem);
+
+ expect(clearSiteByKeyStub.called).to.be.false;
+ });
+
+ it("should show success information message when differences are found", async () => {
+ const siteItem = createMockSiteTreeItem();
+
+ const handler = resyncMetadataDiff(mockPacTerminal as unknown as PacTerminal, mockExtensionContext);
+ await handler(siteItem);
+
+ expect(mockShowInformationMessage.calledOnce).to.be.true;
+ expect(mockShowInformationMessage.firstCall.args[0]).to.equal(Constants.Strings.METADATA_DIFF_RESYNC_COMPLETED);
+ });
+ });
+ });
+});
diff --git a/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFileTreeItem.test.ts b/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFileTreeItem.test.ts
index 6d21ea0cc..b4e220a66 100644
--- a/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFileTreeItem.test.ts
+++ b/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFileTreeItem.test.ts
@@ -182,4 +182,24 @@ describe("MetadataDiffFileTreeItem", () => {
expect(tooltipValue).to.include("Deleted");
});
});
+
+ describe("isImported", () => {
+ it("should return false by default", () => {
+ const treeItem = new MetadataDiffFileTreeItem(mockComparisonResult, "Test Site");
+
+ expect(treeItem.isImported).to.be.false;
+ });
+
+ it("should return false when explicitly set to false", () => {
+ const treeItem = new MetadataDiffFileTreeItem(mockComparisonResult, "Test Site", false);
+
+ expect(treeItem.isImported).to.be.false;
+ });
+
+ it("should return true when explicitly set to true", () => {
+ const treeItem = new MetadataDiffFileTreeItem(mockComparisonResult, "Test Site", true);
+
+ expect(treeItem.isImported).to.be.true;
+ });
+ });
});
diff --git a/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFolderTreeItem.test.ts b/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFolderTreeItem.test.ts
index 8229f739c..ed0041ad1 100644
--- a/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFolderTreeItem.test.ts
+++ b/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFolderTreeItem.test.ts
@@ -132,6 +132,63 @@ describe("MetadataDiffFolderTreeItem", () => {
expect(children).to.include(fileItem);
});
+ it("should return folders first alphabetically, then files alphabetically", () => {
+ const treeItem = new MetadataDiffFolderTreeItem("folder", "Test Site", "folder");
+
+ // Add folders in non-alphabetical order
+ const folderZ = new MetadataDiffFolderTreeItem("z-folder", "Test Site", "folder/z-folder");
+ const folderA = new MetadataDiffFolderTreeItem("a-folder", "Test Site", "folder/a-folder");
+ const folderM = new MetadataDiffFolderTreeItem("m-folder", "Test Site", "folder/m-folder");
+
+ // Add files in non-alphabetical order
+ const fileZ: IFileComparisonResult = {
+ localPath: "/local/z-file.txt",
+ remotePath: "/remote/z-file.txt",
+ relativePath: "folder/z-file.txt",
+ status: "modified"
+ };
+ const fileA: IFileComparisonResult = {
+ localPath: "/local/a-file.txt",
+ remotePath: "/remote/a-file.txt",
+ relativePath: "folder/a-file.txt",
+ status: "added"
+ };
+ const fileM: IFileComparisonResult = {
+ localPath: "/local/m-file.txt",
+ remotePath: "/remote/m-file.txt",
+ relativePath: "folder/m-file.txt",
+ status: "deleted"
+ };
+
+ // Add in random order
+ treeItem.childrenMap.set("z-file.txt", new MetadataDiffFileTreeItem(fileZ, "Test Site"));
+ treeItem.childrenMap.set("z-folder", folderZ);
+ treeItem.childrenMap.set("a-file.txt", new MetadataDiffFileTreeItem(fileA, "Test Site"));
+ treeItem.childrenMap.set("m-folder", folderM);
+ treeItem.childrenMap.set("m-file.txt", new MetadataDiffFileTreeItem(fileM, "Test Site"));
+ treeItem.childrenMap.set("a-folder", folderA);
+
+ const children = treeItem.getChildren();
+
+ expect(children).to.have.lengthOf(6);
+
+ // First 3 should be folders in alphabetical order
+ expect(children[0]).to.be.instanceOf(MetadataDiffFolderTreeItem);
+ expect(children[0].label).to.equal("a-folder");
+ expect(children[1]).to.be.instanceOf(MetadataDiffFolderTreeItem);
+ expect(children[1].label).to.equal("m-folder");
+ expect(children[2]).to.be.instanceOf(MetadataDiffFolderTreeItem);
+ expect(children[2].label).to.equal("z-folder");
+
+ // Last 3 should be files in alphabetical order
+ expect(children[3]).to.be.instanceOf(MetadataDiffFileTreeItem);
+ expect(children[3].label).to.equal("a-file.txt");
+ expect(children[4]).to.be.instanceOf(MetadataDiffFileTreeItem);
+ expect(children[4].label).to.equal("m-file.txt");
+ expect(children[5]).to.be.instanceOf(MetadataDiffFileTreeItem);
+ expect(children[5].label).to.equal("z-file.txt");
+ });
+
it("should return ActionsHubTreeItem array", () => {
const treeItem = new MetadataDiffFolderTreeItem("folder", "Test Site", "folder");
const comparisonResult: IFileComparisonResult = {
diff --git a/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffGroupTreeItem.test.ts b/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffGroupTreeItem.test.ts
index 291d5bf0c..13914e266 100644
--- a/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffGroupTreeItem.test.ts
+++ b/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffGroupTreeItem.test.ts
@@ -53,7 +53,7 @@ describe("MetadataDiffGroupTreeItem", () => {
status: "added"
}
];
- MetadataDiffContext.setResults(results, "Test Site", "Test Environment");
+ MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id");
const treeItem = new MetadataDiffGroupTreeItem();
expect(treeItem.label).to.equal("Metadata Diff");
@@ -74,7 +74,7 @@ describe("MetadataDiffGroupTreeItem", () => {
status: "modified"
}
];
- MetadataDiffContext.setResults(results, "Test Site", "Test Environment");
+ MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id");
const treeItem = new MetadataDiffGroupTreeItem();
expect(treeItem.collapsibleState).to.equal(vscode.TreeItemCollapsibleState.Expanded);
@@ -101,7 +101,7 @@ describe("MetadataDiffGroupTreeItem", () => {
status: "modified"
}
];
- MetadataDiffContext.setResults(results, "Test Site", "Test Environment");
+ MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id");
const treeItem = new MetadataDiffGroupTreeItem();
expect(treeItem.contextValue).to.equal(Constants.ContextValues.METADATA_DIFF_GROUP_WITH_RESULTS);
@@ -122,7 +122,7 @@ describe("MetadataDiffGroupTreeItem", () => {
status: "modified"
}
];
- MetadataDiffContext.setResults(results, "Test Site", "Test Environment");
+ MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id");
const treeItem = new MetadataDiffGroupTreeItem();
expect(treeItem.id).to.equal("metadataDiffGroup-withResults");
@@ -147,7 +147,7 @@ describe("MetadataDiffGroupTreeItem", () => {
status: "modified"
}
];
- MetadataDiffContext.setResults(results, "Test Site", "Test Environment");
+ MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id");
const treeItem = new MetadataDiffGroupTreeItem();
const children = treeItem.getChildren();
@@ -173,8 +173,8 @@ describe("MetadataDiffGroupTreeItem", () => {
status: "added"
}
];
- MetadataDiffContext.setResults(results1, "Site 1", "Test Environment");
- MetadataDiffContext.setResults(results2, "Site 2", "Test Environment");
+ MetadataDiffContext.setResults(results1, "Site 1", "Local Site 1", "Test Environment", "test-website-id-1", "test-environment-id");
+ MetadataDiffContext.setResults(results2, "Site 2", "Local Site 2", "Test Environment", "test-website-id-2", "test-environment-id");
const treeItem = new MetadataDiffGroupTreeItem();
const children = treeItem.getChildren();
@@ -207,8 +207,8 @@ describe("MetadataDiffGroupTreeItem", () => {
status: "deleted"
}
];
- MetadataDiffContext.setResults(results1, "Test Site", "Test Environment");
- MetadataDiffContext.setResults(results2, "Test Site", "Test Environment");
+ MetadataDiffContext.setResults(results1, "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id");
+ MetadataDiffContext.setResults(results2, "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id");
const treeItem = new MetadataDiffGroupTreeItem();
const children = treeItem.getChildren();
diff --git a/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffSiteTreeItem.test.ts b/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffSiteTreeItem.test.ts
index fe6020336..478678291 100644
--- a/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffSiteTreeItem.test.ts
+++ b/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffSiteTreeItem.test.ts
@@ -9,10 +9,35 @@ import { MetadataDiffSiteTreeItem } from "../../../../../../power-pages/actions-
import { MetadataDiffFileTreeItem } from "../../../../../../power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFileTreeItem";
import { MetadataDiffFolderTreeItem } from "../../../../../../power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFolderTreeItem";
import { ActionsHubTreeItem } from "../../../../../../power-pages/actions-hub/tree-items/ActionsHubTreeItem";
-import { IFileComparisonResult, FileComparisonStatus } from "../../../../../../power-pages/actions-hub/models/IFileComparisonResult";
+import { IFileComparisonResult, FileComparisonStatus, ISiteComparisonResults } from "../../../../../../power-pages/actions-hub/models/IFileComparisonResult";
import { Constants } from "../../../../../../power-pages/actions-hub/Constants";
import MetadataDiffContext, { MetadataDiffViewMode, MetadataDiffSortMode } from "../../../../../../power-pages/actions-hub/MetadataDiffContext";
+/**
+ * Helper function to create ISiteComparisonResults for testing
+ */
+function createSiteResults(
+ comparisonResults: IFileComparisonResult[],
+ siteName = "Test Site",
+ localSiteName = "Local Test Site",
+ environmentName = "Test Environment",
+ websiteId = "test-website-id",
+ environmentId = "test-environment-id",
+ isImported = false,
+ exportedAt?: string
+): ISiteComparisonResults {
+ return {
+ comparisonResults,
+ siteName,
+ localSiteName,
+ environmentName,
+ websiteId,
+ environmentId,
+ isImported,
+ exportedAt
+ };
+}
+
describe("MetadataDiffSiteTreeItem", () => {
beforeEach(() => {
// Reset to default list view mode before each test
@@ -21,12 +46,12 @@ describe("MetadataDiffSiteTreeItem", () => {
describe("constructor", () => {
it("should be an instance of ActionsHubTreeItem", () => {
- const treeItem = new MetadataDiffSiteTreeItem([], "Test Site", "Test Environment");
+ const treeItem = new MetadataDiffSiteTreeItem(createSiteResults([]));
expect(treeItem).to.be.instanceOf(ActionsHubTreeItem);
});
- it("should have the expected label with site name and change count", () => {
+ it("should have the expected label with site name", () => {
const results: IFileComparisonResult[] = [
{
localPath: "/local/file1.txt",
@@ -41,34 +66,55 @@ describe("MetadataDiffSiteTreeItem", () => {
status: "added"
}
];
- const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment");
+ const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results));
expect(treeItem.label).to.include("Test Site");
- expect(treeItem.label).to.include("2");
+ expect(treeItem.label).to.include("Test Environment");
+ expect(treeItem.label).to.include("Local Test Site");
});
it("should have expanded collapsible state", () => {
- const treeItem = new MetadataDiffSiteTreeItem([], "Test Site", "Test Environment");
+ const treeItem = new MetadataDiffSiteTreeItem(createSiteResults([]));
expect(treeItem.collapsibleState).to.equal(vscode.TreeItemCollapsibleState.Expanded);
});
it("should have the globe icon", () => {
- const treeItem = new MetadataDiffSiteTreeItem([], "Test Site", "Test Environment");
+ const treeItem = new MetadataDiffSiteTreeItem(createSiteResults([]));
expect((treeItem.iconPath as vscode.ThemeIcon).id).to.equal("globe");
});
it("should have the expected context value", () => {
- const treeItem = new MetadataDiffSiteTreeItem([], "Test Site", "Test Environment");
+ const treeItem = new MetadataDiffSiteTreeItem(createSiteResults([]));
expect(treeItem.contextValue).to.equal(Constants.ContextValues.METADATA_DIFF_SITE);
});
+
+ it("should have imported icon and context value for imported comparisons", () => {
+ const treeItem = new MetadataDiffSiteTreeItem(createSiteResults([], "Test Site", "Local Test Site", "Test Environment", "web-id", "env-id", true, "2024-01-15T10:30:00Z"));
+
+ expect((treeItem.iconPath as vscode.ThemeIcon).id).to.equal("cloud-download");
+ expect(treeItem.contextValue).to.equal(Constants.ContextValues.METADATA_DIFF_SITE_IMPORTED);
+ });
+
+ it("should store exportedAt for imported comparisons", () => {
+ const treeItem = new MetadataDiffSiteTreeItem(createSiteResults([
+ {
+ localPath: "/local/file1.txt",
+ remotePath: "/remote/file1.txt",
+ relativePath: "file1.txt",
+ status: "modified"
+ }
+ ], "Test Site", "Local Test Site", "Test Environment", "web-id", "env-id", true, "2024-01-15T10:30:00Z"));
+
+ expect(treeItem.exportedAt).to.equal("2024-01-15T10:30:00Z");
+ });
});
describe("siteName", () => {
it("should return the site name", () => {
- const treeItem = new MetadataDiffSiteTreeItem([], "Test Site", "Test Environment");
+ const treeItem = new MetadataDiffSiteTreeItem(createSiteResults([]));
expect(treeItem.siteName).to.equal("Test Site");
});
@@ -76,15 +122,30 @@ describe("MetadataDiffSiteTreeItem", () => {
describe("environmentName", () => {
it("should return the environment name", () => {
- const treeItem = new MetadataDiffSiteTreeItem([], "Test Site", "My Environment");
+ const treeItem = new MetadataDiffSiteTreeItem(createSiteResults([], "Test Site", "Local Test Site", "My Environment"));
expect(treeItem.environmentName).to.equal("My Environment");
});
- it("should show environment name as description", () => {
- const treeItem = new MetadataDiffSiteTreeItem([], "Test Site", "My Environment");
+ it("should show file count as description", () => {
+ const results: IFileComparisonResult[] = [
+ {
+ localPath: "/local/file1.txt",
+ remotePath: "/remote/file1.txt",
+ relativePath: "file1.txt",
+ status: "modified"
+ },
+ {
+ localPath: "/local/file2.txt",
+ remotePath: "/remote/file2.txt",
+ relativePath: "file2.txt",
+ status: "added"
+ }
+ ];
+ const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results, "Test Site", "Local Test Site", "My Environment"));
- expect(treeItem.description).to.equal("My Environment");
+ expect(treeItem.description).to.include("2");
+ expect(treeItem.description).to.include("files changed");
});
});
@@ -98,19 +159,33 @@ describe("MetadataDiffSiteTreeItem", () => {
status: "modified"
}
];
- const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment");
+ const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results));
expect(treeItem.comparisonResults).to.deep.equal(results);
});
});
+ describe("websiteId and environmentId", () => {
+ it("should return the websiteId", () => {
+ const treeItem = new MetadataDiffSiteTreeItem(createSiteResults([], "Test Site", "Local Test Site", "Test Environment", "my-website-id", "my-env-id"));
+
+ expect(treeItem.websiteId).to.equal("my-website-id");
+ });
+
+ it("should return the environmentId", () => {
+ const treeItem = new MetadataDiffSiteTreeItem(createSiteResults([], "Test Site", "Local Test Site", "Test Environment", "my-website-id", "my-env-id"));
+
+ expect(treeItem.environmentId).to.equal("my-env-id");
+ });
+ });
+
describe("getChildren - list view mode", () => {
beforeEach(() => {
MetadataDiffContext.setViewMode(MetadataDiffViewMode.List);
});
it("should return empty array when no results", () => {
- const treeItem = new MetadataDiffSiteTreeItem([], "Test Site", "Test Environment");
+ const treeItem = new MetadataDiffSiteTreeItem(createSiteResults([]));
const children = treeItem.getChildren();
@@ -126,7 +201,7 @@ describe("MetadataDiffSiteTreeItem", () => {
status: "modified"
}
];
- const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment");
+ const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results));
const children = treeItem.getChildren();
@@ -143,7 +218,7 @@ describe("MetadataDiffSiteTreeItem", () => {
status: "modified"
}
];
- const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment");
+ const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results));
const children = treeItem.getChildren();
@@ -161,7 +236,7 @@ describe("MetadataDiffSiteTreeItem", () => {
status: "modified"
}
];
- const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment");
+ const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results));
const children = treeItem.getChildren();
@@ -185,7 +260,7 @@ describe("MetadataDiffSiteTreeItem", () => {
status: "added"
}
];
- const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment");
+ const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results));
const children = treeItem.getChildren();
@@ -209,7 +284,7 @@ describe("MetadataDiffSiteTreeItem", () => {
status: "added"
}
];
- const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment");
+ const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results));
const children = treeItem.getChildren();
@@ -240,7 +315,7 @@ describe("MetadataDiffSiteTreeItem", () => {
status: FileComparisonStatus.ADDED
}
];
- const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment");
+ const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results));
const children = treeItem.getChildren();
@@ -265,7 +340,7 @@ describe("MetadataDiffSiteTreeItem", () => {
status: FileComparisonStatus.ADDED
}
];
- const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment");
+ const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results));
const children = treeItem.getChildren();
@@ -297,7 +372,7 @@ describe("MetadataDiffSiteTreeItem", () => {
status: FileComparisonStatus.DELETED
}
];
- const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment");
+ const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results));
const children = treeItem.getChildren();
@@ -324,7 +399,7 @@ describe("MetadataDiffSiteTreeItem", () => {
status: FileComparisonStatus.ADDED
}
];
- const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment");
+ const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results));
const children = treeItem.getChildren();
@@ -349,7 +424,7 @@ describe("MetadataDiffSiteTreeItem", () => {
status: "modified"
}
];
- const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment");
+ const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results));
const children = treeItem.getChildren();
@@ -366,7 +441,7 @@ describe("MetadataDiffSiteTreeItem", () => {
status: "modified"
}
];
- const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment");
+ const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results));
const children = treeItem.getChildren();
@@ -384,7 +459,7 @@ describe("MetadataDiffSiteTreeItem", () => {
status: "modified"
}
];
- const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment");
+ const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results));
const children = treeItem.getChildren();
@@ -414,7 +489,7 @@ describe("MetadataDiffSiteTreeItem", () => {
status: "added"
}
];
- const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment");
+ const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results));
const children = treeItem.getChildren();
@@ -427,7 +502,7 @@ describe("MetadataDiffSiteTreeItem", () => {
expect(folderChildren).to.have.lengthOf(2);
});
- it("should handle mixed root files and folders", () => {
+ it("should handle mixed root files and folders with folders first alphabetically, then files", () => {
const results: IFileComparisonResult[] = [
{
localPath: "/local/root-file.txt",
@@ -442,17 +517,79 @@ describe("MetadataDiffSiteTreeItem", () => {
status: "added"
}
];
- const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment");
+ const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results));
const children = treeItem.getChildren();
expect(children).to.have.lengthOf(2);
- const fileItem = children.find(c => c instanceof MetadataDiffFileTreeItem);
- const folderItem = children.find(c => c instanceof MetadataDiffFolderTreeItem);
+ // Folders should come first, then files
+ expect(children[0]).to.be.instanceOf(MetadataDiffFolderTreeItem);
+ expect(children[0].label).to.equal("folder");
+ expect(children[1]).to.be.instanceOf(MetadataDiffFileTreeItem);
+ expect(children[1].label).to.equal("root-file.txt");
+ });
- expect(fileItem).to.not.be.undefined;
- expect(folderItem).to.not.be.undefined;
+ it("should sort folders alphabetically and files alphabetically at root level", () => {
+ const results: IFileComparisonResult[] = [
+ {
+ localPath: "/local/z-file.txt",
+ remotePath: "/remote/z-file.txt",
+ relativePath: "z-file.txt",
+ status: "modified"
+ },
+ {
+ localPath: "/local/a-folder/file.txt",
+ remotePath: "/remote/a-folder/file.txt",
+ relativePath: "a-folder/file.txt",
+ status: "added"
+ },
+ {
+ localPath: "/local/a-file.txt",
+ remotePath: "/remote/a-file.txt",
+ relativePath: "a-file.txt",
+ status: "modified"
+ },
+ {
+ localPath: "/local/z-folder/file.txt",
+ remotePath: "/remote/z-folder/file.txt",
+ relativePath: "z-folder/file.txt",
+ status: "deleted"
+ },
+ {
+ localPath: "/local/m-folder/file.txt",
+ remotePath: "/remote/m-folder/file.txt",
+ relativePath: "m-folder/file.txt",
+ status: "modified"
+ },
+ {
+ localPath: "/local/m-file.txt",
+ remotePath: "/remote/m-file.txt",
+ relativePath: "m-file.txt",
+ status: "added"
+ }
+ ];
+ const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results));
+
+ const children = treeItem.getChildren();
+
+ expect(children).to.have.lengthOf(6);
+
+ // First 3 should be folders in alphabetical order
+ expect(children[0]).to.be.instanceOf(MetadataDiffFolderTreeItem);
+ expect(children[0].label).to.equal("a-folder");
+ expect(children[1]).to.be.instanceOf(MetadataDiffFolderTreeItem);
+ expect(children[1].label).to.equal("m-folder");
+ expect(children[2]).to.be.instanceOf(MetadataDiffFolderTreeItem);
+ expect(children[2].label).to.equal("z-folder");
+
+ // Last 3 should be files in alphabetical order
+ expect(children[3]).to.be.instanceOf(MetadataDiffFileTreeItem);
+ expect(children[3].label).to.equal("a-file.txt");
+ expect(children[4]).to.be.instanceOf(MetadataDiffFileTreeItem);
+ expect(children[4].label).to.equal("m-file.txt");
+ expect(children[5]).to.be.instanceOf(MetadataDiffFileTreeItem);
+ expect(children[5].label).to.equal("z-file.txt");
});
it("should handle backslash path separators", () => {
@@ -464,7 +601,7 @@ describe("MetadataDiffSiteTreeItem", () => {
status: "modified"
}
];
- const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment");
+ const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results));
const children = treeItem.getChildren();
diff --git a/src/common/utilities/WorkspaceInfoFinderUtil.ts b/src/common/utilities/WorkspaceInfoFinderUtil.ts
index b6ceb2549..8812befd2 100644
--- a/src/common/utilities/WorkspaceInfoFinderUtil.ts
+++ b/src/common/utilities/WorkspaceInfoFinderUtil.ts
@@ -74,6 +74,42 @@ export function getWebsiteRecordId(param: { uri: string }[] | string): string {
return "";
}
+/**
+ * Gets the website name from the website.yml file in the specified directory
+ * @param workspaceFolderPath The directory path containing website.yml
+ * @returns The website name (adx_name or name field), or empty string if not found
+ */
+export function getWebsiteName(workspaceFolderPath: string): string {
+ try {
+ if (!workspaceFolderPath) {
+ return "";
+ }
+
+ // Check for website.yml directly in the folder first
+ let websiteYmlPath = path.join(workspaceFolderPath, WEBSITE_YML);
+ if (!fs.existsSync(websiteYmlPath)) {
+ // Also check inside the .powerpages-site folder
+ websiteYmlPath = path.join(workspaceFolderPath, POWERPAGES_SITE_FOLDER, WEBSITE_YML);
+ }
+
+ if (fs.existsSync(websiteYmlPath)) {
+ const fileContent = fs.readFileSync(websiteYmlPath, 'utf8');
+ const parsedYaml = parse(fileContent);
+ if (parsedYaml) {
+ // Check for adx_name first, then fallback to name (to support different formats)
+ if (parsedYaml.adx_name) {
+ return parsedYaml.adx_name;
+ } else if (parsedYaml.name) {
+ return parsedYaml.name;
+ }
+ }
+ }
+ } catch {
+ // Silently fail and return empty string
+ }
+ return "";
+}
+
export function findWebsiteYmlFolder(startPath: string): string | null {
let currentPath = startPath;
while (currentPath) {