Source code
Revision control
Copy as Markdown
Other Tools
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
"use strict";
/**
* Finite State Machine (FSM) context for interactive debugging of tracker blocking.
* This class manages the state transitions and interactions for the debugging process.
*/
class DebuggerFSMContext {
/**
* Creates an instance of DebuggerFSMContext.
*
* @param {Array} allTrackers - A non-empty list of all trackers to be managed by the FSM context.
* @param {Object} callbacks - An object containing optional callback functions.
* @param {Function} [callbacks.onPromptTextUpdate] - Callback invoked when the prompt text is updated.
* @param {Function} [callbacks.onButtonStateUpdate] - Callback invoked when the button state is updated.
* @param {Function} [callbacks.onTrackersBlockedStateUpdate] - Callback invoked when the trackers blocked state is updated.
*/
constructor(
allTrackers,
{ onPromptTextUpdate, onButtonStateUpdate, onTrackersBlockedStateUpdate }
) {
this.allTrackers = allTrackers;
this.subdomainStageTrackers = new Set();
this.necessaryTrackers = new Set();
this.onTrackersBlockedStateUpdate =
onTrackersBlockedStateUpdate || (() => {});
this.onPromptTextUpdate = onPromptTextUpdate || (() => {});
this.onButtonStateUpdate = onButtonStateUpdate || (() => {});
this.onTrackersBlockedStateUpdate(false, this.allTrackers);
this.onButtonStateUpdate("stop-debugging", false);
this.changeState(new DomainStageState(this));
}
/**
* Transition to a new FSM state.
* @param {object} state - The new state instance.
*/
changeState(state) {
this.state = state;
}
/**
* Called when user clicks "Continue".
*/
async onTestNextTracker() {
await this.state.onTestNextTracker();
}
/**
* Called when user clicks "Website Broke".
*/
async onWebsiteBroke() {
await this.state.onWebsiteBroke();
}
/**
* Stop interactive debugging and reset all states.
*/
async stop() {
this.onPromptTextUpdate(undefined, `Interactive debugger stopped.`);
await this.onTrackersBlockedStateUpdate(false, this.allTrackers);
this.onButtonStateUpdate("test-next-tracker", true);
this.onButtonStateUpdate("website-broke", true);
this.onButtonStateUpdate("stop-debugging", true);
}
}
class FSMState {
/**
* Base class for FSM states.
* @param {DebuggerFSMContext} debuggerFSMContext - The FSM context.
*/
constructor(debuggerFSMContext) {
this.debuggerFSMContext = debuggerFSMContext;
}
/**
* Handle the "Continue" button click to test the next tracker.
*/
onTestNextTracker() {
throw new Error("onTestNextTracker must be implemented in derived class");
}
/**
* Handle the "Website Broke" button click to unblock the last tracker.
*/
onWebsiteBroke() {
throw new Error("onWebsiteBroke must be implemented in derived class");
}
}
/**
* This stage groups trackers by their top-level domain and allows the user to test
* each domain group separately. If a website breaks, the user can use `onWebsiteBroke`
* to unblock the last group and continue testing the next domain group.
* If the website is not broken, the user can use `onTestNextTracker` to test the next domain group.
* If all groups are tested, it transitions to the individual domain stage.
*/
class DomainStageState extends FSMState {
constructor(debuggerFSMContext) {
super(debuggerFSMContext);
this.domainGroups = this.groupByDomain(debuggerFSMContext.allTrackers);
this.lastGroup = null;
this.debuggerFSMContext.onPromptTextUpdate(
this.domainGroups.length,
"Click 'Continue' to start domain debugging."
);
this.debuggerFSMContext.onButtonStateUpdate("test-next-tracker", false);
}
/**
* Handle the "Continue" button click to test the next domain group.
*/
async onTestNextTracker() {
this.lastGroup = this.domainGroups.shift();
const count = this.domainGroups.length;
// If no more domain groups to check, transition to subdomain stage
if (!this.lastGroup) {
this.debuggerFSMContext.onPromptTextUpdate(
count,
"Domain debugging finished. Starting subdomain tracker stage. Click 'Continue' to proceed."
);
this.debuggerFSMContext.onButtonStateUpdate("website-broke", true);
this.debuggerFSMContext.changeState(
new SubdomainStageState(this.debuggerFSMContext)
);
return;
}
await this.debuggerFSMContext.onTrackersBlockedStateUpdate(
true,
this.lastGroup.hosts
);
this.debuggerFSMContext.onButtonStateUpdate("website-broke", false);
this.debuggerFSMContext.onPromptTextUpdate(
count,
`Blocked domain group '${this.lastGroup.domain}'. If the website is broken, click 'Website Broke', otherwise 'Continue'.`
);
}
/**
* Handle the "Website Broke" button click to unblock the last group.
*/
async onWebsiteBroke() {
if (this.lastGroup && this.lastGroup.hosts) {
this.lastGroup.hosts.forEach(tracker =>
this.debuggerFSMContext.subdomainStageTrackers.add(tracker)
);
// Unblock the group to restore site
await this.debuggerFSMContext.onTrackersBlockedStateUpdate(
false,
this.lastGroup.hosts
);
const count = this.domainGroups.length;
this.debuggerFSMContext.onPromptTextUpdate(
count,
`Domain group '${this.lastGroup.domain}' will be tested individually later. Click 'Continue' to test the next domain group.`
);
this.debuggerFSMContext.onButtonStateUpdate("website-broke", true);
}
}
/**
* Group trackers by their top-level domain.
* @param {string[]} trackers - List of tracker hostnames.
*/
groupByDomain(trackers) {
const domainGroupsMap = {};
trackers.forEach(tracker => {
const domain = Services.eTLD.getBaseDomainFromHost(tracker);
if (!domainGroupsMap[domain]) {
domainGroupsMap[domain] = new Set();
}
domainGroupsMap[domain].add(tracker);
});
return Object.entries(domainGroupsMap).map(([domain, hosts]) => ({
domain,
hosts: Array.from(hosts),
}));
}
}
/**
* Subdomain stage: block/unblock each tracker separately. This stage is very similar to the domain stage,
* but it allows the user to test each subdomain tracker individually. This ensures we can identify
* which specific subdomain tracker is causing issues, rather than just the top-level domain.
*/
class SubdomainStageState extends FSMState {
constructor(debuggerFSMContext) {
super(debuggerFSMContext);
this.subdomains = Array.from(debuggerFSMContext.subdomainStageTrackers);
this.lastSubdomain = null;
}
async onTestNextTracker() {
this.lastSubdomain = this.subdomains.shift();
const count = this.subdomains.length;
if (!this.lastSubdomain) {
this.debuggerFSMContext.changeState(
new CompletedState(this.debuggerFSMContext)
);
return;
}
await this.debuggerFSMContext.onTrackersBlockedStateUpdate(true, [
this.lastSubdomain,
]);
this.debuggerFSMContext.onButtonStateUpdate("website-broke", false);
this.debuggerFSMContext.onPromptTextUpdate(
count,
`Blocked subdomain '${this.lastSubdomain}'. If the website is broken, click 'Website Broke', otherwise 'Continue'.`
);
}
async onWebsiteBroke() {
if (this.lastSubdomain) {
this.debuggerFSMContext.necessaryTrackers.add(this.lastSubdomain);
await this.debuggerFSMContext.onTrackersBlockedStateUpdate(false, [
this.lastSubdomain,
]);
const count = this.subdomains.length;
this.debuggerFSMContext.onButtonStateUpdate("website-broke", true);
this.debuggerFSMContext.onPromptTextUpdate(
count,
`Added '${this.lastSubdomain}' to necessary trackers. Click 'Continue' to test the next subdomain.`
);
}
}
}
class CompletedState extends FSMState {
constructor(debuggerFSMContext) {
super(debuggerFSMContext);
this.debuggerFSMContext.onPromptTextUpdate(
undefined,
`Subdomain debugging finished. Please add the following to the exceptions list: ${Array.from(this.debuggerFSMContext.necessaryTrackers).join(", ")}`,
true
);
this.debuggerFSMContext.onButtonStateUpdate("test-next-tracker", true);
this.debuggerFSMContext.onButtonStateUpdate("website-broke", true);
this.debuggerFSMContext.onButtonStateUpdate("stop-debugging", true);
}
}
// Only export for Node.js (test) environments
if (typeof module !== "undefined" && module.exports) {
module.exports = {
DebuggerFSMContext,
DomainStageState,
SubdomainStageState,
CompletedState,
};
}
exports.DebuggerFSMContext = DebuggerFSMContext;