rorMessage,
          sourceName,
          lineNumber,
          columnNumber,
          logLevel,
        } = error;
        const message = {
          level: ConsoleLogHelper.LOG_LEVELS[logLevel],
          log: [errorMessage],
          uri: sourceName,
          pos: `${lineNumber}:${columnNumber}`,
        };
        return { timeStamp, message };
      });
  }

  #getPreview(value) {
    switch (typeof value) {
      case "symbol":
        return value.toString();

      case "function":
        return "function ()";

      case "object":
        if (value === null) {
          return null;
        }
        if (Array.isArray(value)) {
          return `(${value.length})[...]`;
        }
        return "{...}";

      case "undefined":
        return "undefined";

      default:
        try {
          structuredClone(value);
        } catch (_) {
          return `${value}` || "?";
        }
        return value;
    }
  }

  #getArrayPreview(arr) {
    const preview = [];
    let count = 0;
    for (const value of arr) {
      if (++count > ConsoleLogHelper.PREVIEW_MAX_ITEMS) {
        break;
      }
      preview.push(this.#getPreview(value));
    }

    return preview;
  }

  #getObjectPreview(obj) {
    const preview = {};
    let count = 0;
    for (const key of Object.keys(obj)) {
      if (++count > ConsoleLogHelper.PREVIEW_MAX_ITEMS) {
        break;
      }
      preview[key] = this.#getPreview(obj[key]);
    }

    return preview;
  }

  #getArgs(value) {
    if (typeof value === "object" && value !== null) {
      if (Array.isArray(value)) {
        return this.#getArrayPreview(value);
      }
      return this.#getObjectPreview(value);
    }

    return this.#getPreview(value);
  }
}

const FrameworkDetector = {
  hasFastClickPageScript(window) {
    if (window.FastClick) {
      return true;
    }

    for (const property in window) {
      try {
        const proto = window[property].prototype;
        if (proto && proto.needsClick) {
          return true;
        }
      } catch (_) {}
    }

    return false;
  },

  hasMobifyPageScript(window) {
    return !!window.Mobify?.Tag;
  },

  hasMarfeelPageScript(window) {
    return !!window.marfeel;
  },

  checkWindow(window) {
    try {
      const script = `
        (function() {
          function ${FrameworkDetector.hasFastClickPageScript};
          function ${FrameworkDetector.hasMobifyPageScript};
          function ${FrameworkDetector.hasMarfeelPageScript};
          const win = window.wrappedJSObject || window;
          return {
            fastclick: hasFastClickPageScript(win),
            mobify: hasMobifyPageScript(win),
            marfeel: hasMarfeelPageScript(win),
          }
        })();
      `;
      return RunScriptInFrame(window, script);
    } catch (e) {
      console.error(
        "GetWebcompatInfoFromParentProcess: Error detecting JS frameworks",
        e
      );
      return {
        fastclick: false,
        mobify: false,
        marfeel: false,
      };
    }
  },
};

export class ReportBrokenSiteChild extends JSWindowActorChild {
  async #getBrokenSiteReport(docShell) {
    let consoleLog = [];
    try {
      consoleLog = await this.#getConsoleLogs(docShell);
    } catch (_) {}

    const { frameworks, languages, userAgent, url } =
      this.#getInfoFromChild(docShell);

    const { antitracking, browser, devicePixelRatio, screenshot } =
      await this.sendQuery(
        "GetWebcompatInfoFromParentProcess",
        SCREENSHOT_FORMAT
      );

    const reportData = {
      tabInfo: {
        consoleLog: {
          value: consoleLog,
          do_not_preview: true,
          // Only sent to webcompat.com with send more info, not with Glean.
        },
        languages: {
          value: languages,
          glean: "tabInfo",
        },
        screenshot: {
          value: screenshot,
          do_not_preview: true,
          // Binary data not sent by Glean
        },
        url: {
          value: url,
          do_not_preview: true,
          // Duplicate value used only for sanity-checking.
        },
        useragentString: {
          value: userAgent,
          glean: "tabInfo",
        },
      },
      graphics: {
        devicePixelRatio: {
          value: devicePixelRatio,
          glean: "browserInfo.graphics",
        },
        devices: {
          json: true,
          value: browser.graphics.devices,
          glean: "browserInfo.graphics",
        },
        drivers: {
          json: true,
          value: browser.graphics.drivers,
          glean: "browserInfo.graphics",
        },
        features: {
          json: true,
          value: browser.graphics.features,
          glean: "browserInfo.graphics",
        },
        hasTouchScreen: {
          value: browser.graphics.hasTouchScreen,
          glean: "browserInfo.graphics",
        },
        monitors: {
          json: true,
          value: browser.graphics.monitors,
          glean: "browserInfo.graphics",
        },
      },
      antitracking: {
        blockList: {
          value: antitracking.blockList,
          glean: "tabInfo.antitracking",
        },
        blockedOrigins: {
          value: antitracking.blockedOrigins,
          glean: "tabInfo.antitracking",
        },
        isPrivateBrowsing: {
          value: antitracking.isPrivateBrowsing,
          glean: "tabInfo.antitracking",
        },
        hasMixedActiveContentBlocked: {
          value: antitracking.hasMixedActiveContentBlocked,
          glean: "tabInfo.antitracking",
        },
        hasMixedDisplayContentBlocked: {
          value: antitracking.hasMixedDisplayContentBlocked,
          glean: "tabInfo.antitracking",
        },
        hasTrackingContentBlocked: {
          value: antitracking.hasTrackingContentBlocked,
          glean: "tabInfo.antitracking",
        },
        btpHasPurgedSite: {
          value: antitracking.btpHasPurgedSite,
          glean: "tabInfo.antitracking",
        },
        etpCategory: {
          value: antitracking.etpCategory,
          glean: "tabInfo.antitracking",
        },
      },
      frameworks: {
        fastclick: {
          value: frameworks.fastclick,
          glean: "tabInfo.frameworks",
        },
        marfeel: {
          value: frameworks.marfeel,
          glean: "tabInfo.frameworks",
        },
        mobify: {
          value: frameworks.mobify,
          glean: "tabInfo.frameworks",
        },
      },
      browserInfo: {
        addons: {
          value: browser.addons,
          glean: "browserInfo",
        },
        experiments: {
          value: browser.experiments,
          glean: "browserInfo",
        },
      },
      app: {
        applicationName: {
          value: browser.app.applicationName,
          // Gleans sends this for us in the base ping
        },
        buildId: {
          value: browser.app.buildId,
          // Gleans sends this for us in the base ping
        },
        defaultLocales: {
          value: browser.locales,
          glean: "browserInfo.app",
        },
        defaultUseragentString: {
          value: browser.app.defaultUserAgent,
          glean: "browserInfo.app",
        },
        fissionEnabled: {
          value: browser.platform.fissionEnabled,
          glean: "browserInfo.app",
        },
        platform: {
          do_not_preview: true,
          value: browser.platform.name,
          // Gleans sends this for us in the base ping
        },
        updateChannel: {
          value: browser.app.updateChannel,
          // Gleans sends this for us in the base ping
        },
        version: {
          value: browser.app.version,
          // Gleans sends this for us in the base ping
        },
      },
      system: {
        isTablet: {
          value: browser.platform.isTablet ?? false,
          glean: "browserInfo.system",
        },
        memory: {
          value: browser.platform.memoryMB,
          glean: "browserInfo.system",
        },
        osArchitecture: {
          value: browser.platform.osArchitecture,
          // Gleans sends this for us in the base ping
        },
        osName: {
          value: browser.platform.osName,
          // Gleans sends this for us in the base ping
        },
        osVersion: {
          value: browser.platform.osVersion,
          // Gleans sends this for us in the base ping
        },
      },
      prefs: {},
    };

    for (const [label, pref] of Object.entries({
      cookieBehavior: "network.cookie.cookieBehavior",
      forcedAcceleratedLayers: "layers.acceleration.force-enabled",
      globalPrivacyControlEnabled: "privacy.globalprivacycontrol.enabled",
      installtriggerEnabled: "extensions.InstallTrigger.enabled",
      opaqueResponseBlocking: "browser.opaqueResponseBlocking",
      resistFingerprintingEnabled: "privacy.resistFingerprinting",
      softwareWebrender: "gfx.webrender.software",
      thirdPartyCookieBlockingEnabled:
        "network.cookie.cookieBehavior.optInPartitioning",
      thirdPartyCookieBlockingEnabledInPbm:
        "network.cookie.cookieBehavior.optInPartitioning.pbmode",
    })) {
      const value = browser.prefs[pref];
      if (value !== undefined) {
        reportData.prefs[label] = {
          value,
          glean: "browserInfo.prefs",
        };
      }
    }

    if (browser.security) {
      const actuallySet = {};
      for (const name of ["antispyware", "antivirus", "firewall"]) {
        if (browser.security[name]?.length) {
          actuallySet[name] = {
            value: browser.security[name],
            glean: "browserInfo.security",
          };
        }
      }
      if (Object.keys(actuallySet).length) {
        reportData.security = actuallySet;
      }
    }

    return reportData;
  }

  #getInfoFromChild(docShell) {
    const win = docShell.domWindow;

    const frameworks = FrameworkDetector.checkWindow(win);
    const { languages, userAgent } = win.navigator;

    return {
      frameworks,
      languages,
      url: win.location.href,
      userAgent,
    };
  }

  #getWebCompatInfo(docShell) {
    return Promise.all([
      this.#getConsoleLogs(docShell),
      this.sendQuery("GetWebcompatInfoFromParentProcess", SCREENSHOT_FORMAT),
    ])
      .then(([consoleLog, infoFromParent]) => {
        const { frameworks, languages, userAgent, url } =
          this.#getInfoFromChild(docShell);

        const { antitracking, browser, devicePixelRatio, screenshot } =
          infoFromParent;

        return {
          antitracking,
          browser,
          consoleLog,
          devicePixelRatio,
          frameworks,
          languages,
          screenshot,
          url,
          userAgent,
        };
      })
      .catch(err => {
        // Log more output if the actor wasn't just being destroyed.
        if (err.name !== "AbortError") {
          // eslint-disable-next-line no-console
          console.trace("#getWebCompatInfo error", err);
        }
        throw err;
      });
  }

  async #getConsoleLogs() {
    return this.#getLoggedMessages()
      .flat()
      .sort((a, b) => a.timeStamp - b.timeStamp)
      .map(m => m.message);
  }

  #getLoggedMessages(alsoIncludePrivate = false) {
    const windowId = this.contentWindow.windowGlobalChild.innerWindowId;
    const helper = new ConsoleLogHelper(windowId, alsoIncludePrivate);
    return helper.getLoggedMessages();
  }

  #formatReportDataForWebcompatCom({
    reason,
    description,
    reportUrl,
    reporterConfig,
    webcompatInfo,
  }) {
    const extra_labels = reporterConfig?.extra_labels || [];

    const message = Object.assign({}, reporterConfig, {
      url: reportUrl,
      category: reason,
      description,
      details: {},
      extra_labels,
    });

    const payload = {
      message,
    };

    if (webcompatInfo) {
      // Copy the full report data into additionalData, reformatting it nicely.
      const additionalData = {};
      for (const category of Object.values(webcompatInfo)) {
        for (const [name, { do_not_preview, glean, value }] of Object.entries(
          category
        )) {
          if (do_not_preview) {
            continue;
          }
          let target = additionalData;
          for (const step of (glean ?? "browserInfo.app").split(".")) {
            target[step] ??= {};
            target = target[step];
          }
          target[name] = value;
        }
      }

      const { browserInfo, tabInfo } = additionalData;
      const { app, graphics } = browserInfo;
      const { antitracking, frameworks } = tabInfo;
      const { blockList } = antitracking;

      const consoleLog = webcompatInfo.tabInfo.consoleLog.value;
      const screenshot = webcompatInfo.tabInfo.screenshot.value;
      const url = webcompatInfo.tabInfo.url.value;

      message.blockList = blockList;
      const details = Object.assign(message.details, {
        additionalData,
        blockList,
        channel: app.updateChannel,
        defaultUserAgent: app.defaultUseragentString,
        "gfx.webrender.software": webcompatInfo.prefs.softwareWebrender.value,
        hasTouchScreen: graphics.hasTouchScreen,
      });

      // We only care about this pref on Linux right now on webcompat.com.
      if (webcompatInfo.app.platform.value === "linux") {
        details["layers.acceleration.force-enabled"] =
          webcompatInfo.prefs.forcedAcceleratedLayers.value;
      } else {
        delete details.additionalData.browserInfo.prefs.forcedAcceleratedLayers;
      }

      // If the user enters a URL unrelated to the current tab,
      // don't bother sending a screenshot or logs/etc
      let sendRecordedPageSpecificDetails = false;
      const givenUri = URL.parse(reportUrl);
      const recordedUri = URL.parse(url);
      if (givenUri && recordedUri) {
        sendRecordedPageSpecificDetails =
          givenUri.origin == recordedUri.origin &&
          givenUri.pathname == recordedUri.pathname;
      }

      if (sendRecordedPageSpecificDetails) {
        payload.screenshot = screenshot;

        details.consoleLog = consoleLog;
        details.frameworks = frameworks;
        details["mixed active content blocked"] =
          antitracking.hasMixedActiveContentBlocked;
        details["mixed passive content blocked"] =
          antitracking.hasMixedDisplayContentBlocked;
        details["tracking content blocked"] =
          antitracking.hasTrackingContentBlocked
            ? `true (${blockList})`
            : "false";
        details["btp has purged site"] = antitracking.btpHasPurgedSite;

        if (antitracking.hasTrackingContentBlocked) {
          extra_labels.push(`type-tracking-protection-${blockList}`);
        }

        for (const [framework, active] of Object.entries(tabInfo.frameworks)) {
          if (!active) {
            continue;
          }
          details[framework] = true;
          extra_labels.push(`type-${framework}`);
        }

        extra_labels.sort();
      }
    }

    return payload;
  }

  #stripNonASCIIChars(str) {
    // eslint-disable-next-line no-control-regex
    return str.replace(/[^\x00-\x7F]/g, "");
  }

  async receiveMessage(msg) {
    const { docShell } = this;
    switch (msg.name) {
      case "SendDataToWebcompatCom": {
        const win = docShell.domWindow;
        const expectedEndpoint = msg.data.endpointUrl;
        if (win.location.href == expectedEndpoint) {
          // Ensure that the tab has fully loaded and is waiting for messages
          const onLoad = () => {
            const payload = this.#formatReportDataForWebcompatCom(msg.data);
            const json = this.#stripNonASCIIChars(JSON.stringify(payload));
            const expectedOrigin = JSON.stringify(
              new URL(expectedEndpoint).origin
            );
            // webcompat.com checks that the message comes from its own origin
            const script = `
            const wrtReady = window.wrappedJSObject?.wrtReady;
            if (wrtReady) {
              console.info("Report Broken Site is waiting");
            }
            Promise.resolve(wrtReady).then(() => {
              console.debug(${json});
              postMessage(${json}, ${expectedOrigin})
            });`;
            RunScriptInFrame(win, script);
          };
          if (win.document.readyState == "complete") {
            onLoad();
          } else {
            win.addEventListener("load", onLoad, { once: true });
          }
        }
        return null;
      }
      case "GetBrokenSiteReport": {
        return this.#getBrokenSiteReport(docShell);
      }
      case "GetWebCompatInfo": {
        return this.#getWebCompatInfo(docShell);
      }
      case "GetConsoleLog": {
        return this.#getLoggedMessages();
      }
    }
    return null;
  }
}
PK