Azure DevOps Extension Dev Magic : ADO Extension Local Development Helper

Replace production Azure DevOps extension iframe URLs with local development URLs for testing

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Azure DevOps Extension Dev Magic : ADO Extension Local Development Helper
// @namespace    https://github.com/vs4vijay/ado-extension-dev-magic
// @version      1.0.0
// @description  Replace production Azure DevOps extension iframe URLs with local development URLs for testing
// @author       Open Source Community
// @match        https://dev.azure.com/*
// @match        https://*.visualstudio.com/*
// @grant        none
// @run-at       document-start
// @license      MIT
// @homepageURL  https://github.com/vs4vijay/ado-extension-dev-magic
// @supportURL   https://github.com/vs4vijay/ado-extension-dev-magic/issues
// ==/UserScript==

(function () {
  "use strict";

  // ========================================
  // CONFIGURATION - Customize these values
  // ========================================

  /**
   * URL Mappings Configuration
   * Add your extension URLs here to map production URLs to local development URLs
   * Format: { productionUrl: 'localDevUrl' }
   */
  const URL_MAPPINGS = {
    // Example mappings - replace with your actual URLs
    "https://your-production-extension.azurestaticapps.net/": "http://localhost:3000/",

    // Another example
    "https://another-extension.azurewebsites.net/app": "http://localhost:8080/app",
    // Add more mappings as needed
  };

  /**
   * Configuration Settings
   */
  const ENABLE_DEBUG_LOG = true; // Enable console logging
  const CHECK_INTERVAL = 2000; // Check interval for iframe replacement (milliseconds)

  /**
   * Target Page Configuration
   * Specify which Azure DevOps pages to target
   */
  const TARGET_PAGES = {
    pullRequests: true,
    workItems: false,
    builds: false,
    releases: false,
    repos: false,
    // Add more page types as needed
  };

  // ========================================
  // CORE FUNCTIONALITY - Do not modify unless you know what you're doing
  // ========================================

  const SCRIPT_NAME = "ADO Extension Local Dev Helper";

  function log(message, level = "info") {
    if (ENABLE_DEBUG_LOG) {
      const timestamp = new Date().toISOString();
      console[level](`[${timestamp}] ${SCRIPT_NAME}: ${message}`);
    }
  }

  function getReplacementUrl(originalUrl) {
    // Check for exact matches first
    if (URL_MAPPINGS[originalUrl]) {
      return URL_MAPPINGS[originalUrl];
    }

    // Check for partial matches (in case of query parameters or fragments)
    for (const [prodUrl, localUrl] of Object.entries(URL_MAPPINGS)) {
      if (originalUrl.startsWith(prodUrl)) {
        const remainder = originalUrl.substring(prodUrl.length);
        return localUrl + remainder;
      }
    }

    return null;
  }

  function replaceIframeSrc() {
    const iframes = document.querySelectorAll(
      'iframe.external-content--iframe, iframe[src*="azurestaticapps.net"], iframe[src*="azurewebsites.net"]'
    );
    let replacedCount = 0;

    iframes.forEach((iframe) => {
      const originalSrc = iframe.src;
      const replacementUrl = getReplacementUrl(originalSrc);

      if (replacementUrl) {
        log(
          `Found production iframe (${originalSrc}), replacing with local URL (${replacementUrl})`
        );
        iframe.src = replacementUrl;
        replacedCount++;

        // Add visual indicator that this iframe has been replaced
        iframe.style.border = "2px solid #0078d4";
        iframe.title =
          "Local Development - " + (iframe.title || "Extension iframe");
      }
    });

    if (replacedCount > 0) {
      log(
        `Successfully replaced ${replacedCount} iframe(s) with local development URLs`
      );
    }
  }

  function interceptIframeCreation() {
    const originalSetAttribute = HTMLIFrameElement.prototype.setAttribute;

    HTMLIFrameElement.prototype.setAttribute = function (name, value) {
      if (name === "src") {
        const replacementUrl = getReplacementUrl(value);
        if (replacementUrl) {
          log(
            `Intercepting iframe src attribute (${value}), using local URL (${replacementUrl})`
          );
          value = replacementUrl;
        }
      }
      return originalSetAttribute.call(this, name, value);
    };

    // Intercept direct src property assignment
    Object.defineProperty(HTMLIFrameElement.prototype, "src", {
      get: function () {
        return this.getAttribute("src") || "";
      },
      set: function (value) {
        const replacementUrl = getReplacementUrl(value);
        if (replacementUrl) {
          log(
            `Intercepting iframe src property (${value}), using local URL (${replacementUrl})`
          );
          value = replacementUrl;
        }
        this.setAttribute("src", value);
      },
      configurable: true,
      enumerable: true,
    });
  }

  function setupMutationObserver() {
    const observer = new MutationObserver(function (mutations) {
      let shouldCheck = false;

      mutations.forEach(function (mutation) {
        if (mutation.type === "childList") {
          mutation.addedNodes.forEach(function (node) {
            if (node.nodeType === Node.ELEMENT_NODE) {
              if (node.tagName === "IFRAME" || node.querySelector("iframe")) {
                shouldCheck = true;
              }
            }
          });
        } else if (
          mutation.type === "attributes" &&
          mutation.target.tagName === "IFRAME" &&
          mutation.attributeName === "src"
        ) {
          shouldCheck = true;
        }
      });

      if (shouldCheck) {
        setTimeout(replaceIframeSrc, 100);
      }
    });

    if (document.body) {
      observer.observe(document.body, {
        childList: true,
        subtree: true,
        attributes: true,
        attributeFilter: ["src"],
      });
      log("MutationObserver setup complete");
    }
  }

  function isTargetPage() {
    const path = window.location.pathname.toLowerCase();
    const href = window.location.href.toLowerCase();

    return (
      (TARGET_PAGES.pullRequests &&
        (path.includes("/pullrequest/") || href.includes("pullrequest"))) ||
      (TARGET_PAGES.workItems && path.includes("/_workitems/")) ||
      (TARGET_PAGES.builds && path.includes("/_build/")) ||
      (TARGET_PAGES.releases && path.includes("/_release/")) ||
      (TARGET_PAGES.repos && path.includes("/_git/"))
    );
  }

  function init() {
    log("Initializing extension local development helper...");
    log(
      `Configured URL mappings: ${Object.keys(URL_MAPPINGS).length} mapping(s)`
    );

    if (Object.keys(URL_MAPPINGS).length === 0) {
      log(
        "Warning: No URL mappings configured. Please update the URL_MAPPINGS configuration.",
        "warn"
      );
      return;
    }

    interceptIframeCreation();

    if (document.readyState === "loading") {
      document.addEventListener("DOMContentLoaded", function () {
        replaceIframeSrc();
        setupMutationObserver();
      });
    } else {
      replaceIframeSrc();
      setupMutationObserver();
    }

    // Periodic check as fallback
    setInterval(replaceIframeSrc, CHECK_INTERVAL);

    log("Initialization complete");
  }

  // Monitor for page navigation in Azure DevOps SPA
  function setupNavigationMonitor() {
    let lastUrl = location.href;
    const navigationObserver = new MutationObserver(() => {
      const url = location.href;
      if (url !== lastUrl) {
        lastUrl = url;
        if (isTargetPage()) {
          log("Navigated to target page, re-initializing...");
          setTimeout(init, 1000);
        }
      }
    });

    if (document.documentElement) {
      navigationObserver.observe(document.documentElement, {
        subtree: true,
        childList: true,
      });
    }
  }

  // Main execution
  if (isTargetPage()) {
    init();
  }

  setupNavigationMonitor();

  // Expose configuration for runtime modification (optional)
  window.adoExtensionDevHelper = {
    config: {
      urlMappings: URL_MAPPINGS,
      enableDebugLog: ENABLE_DEBUG_LOG,
      checkInterval: CHECK_INTERVAL,
      targetPages: TARGET_PAGES,
    },
    addMapping: function (productionUrl, localUrl) {
      URL_MAPPINGS[productionUrl] = localUrl;
      log(`Added new URL mapping: ${productionUrl} -> ${localUrl}`);
    },
    removeMapping: function (productionUrl) {
      delete URL_MAPPINGS[productionUrl];
      log(`Removed URL mapping: ${productionUrl}`);
    },
    reinitialize: init,
  };
})();