debugger-fsm-context.js (8927B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 /** 8 * Finite State Machine (FSM) context for interactive debugging of tracker blocking. 9 * This class manages the state transitions and interactions for the debugging process. 10 */ 11 class DebuggerFSMContext { 12 /** 13 * Creates an instance of DebuggerFSMContext. 14 * 15 * @param {Array} allTrackers - A non-empty list of all trackers to be managed by the FSM context. 16 * @param {object} callbacks - An object containing optional callback functions. 17 * @param {Function} [callbacks.onPromptTextUpdate] - Callback invoked when the prompt text is updated. 18 * @param {Function} [callbacks.onButtonStateUpdate] - Callback invoked when the button state is updated. 19 * @param {Function} [callbacks.onTrackersBlockedStateUpdate] - Callback invoked when the trackers blocked state is updated. 20 */ 21 constructor( 22 allTrackers, 23 { onPromptTextUpdate, onButtonStateUpdate, onTrackersBlockedStateUpdate } 24 ) { 25 this.allTrackers = allTrackers; 26 this.subdomainStageTrackers = new Set(); 27 this.necessaryTrackers = new Set(); 28 this.onTrackersBlockedStateUpdate = 29 onTrackersBlockedStateUpdate || (() => {}); 30 this.onPromptTextUpdate = onPromptTextUpdate || (() => {}); 31 this.onButtonStateUpdate = onButtonStateUpdate || (() => {}); 32 this.onTrackersBlockedStateUpdate(false, this.allTrackers); 33 this.onButtonStateUpdate("stop-debugging", false); 34 this.changeState(new DomainStageState(this)); 35 } 36 37 /** 38 * Transition to a new FSM state. 39 * 40 * @param {object} state - The new state instance. 41 */ 42 changeState(state) { 43 this.state = state; 44 } 45 46 /** 47 * Called when user clicks "Continue". 48 */ 49 async onTestNextTracker() { 50 await this.state.onTestNextTracker(); 51 } 52 53 /** 54 * Called when user clicks "Website Broke". 55 */ 56 async onWebsiteBroke() { 57 await this.state.onWebsiteBroke(); 58 } 59 60 /** 61 * Stop interactive debugging and reset all states. 62 */ 63 async stop() { 64 this.onPromptTextUpdate(undefined, `Interactive debugger stopped.`); 65 await this.onTrackersBlockedStateUpdate(false, this.allTrackers); 66 this.onButtonStateUpdate("test-next-tracker", true); 67 this.onButtonStateUpdate("website-broke", true); 68 this.onButtonStateUpdate("stop-debugging", true); 69 } 70 } 71 72 class FSMState { 73 /** 74 * Base class for FSM states. 75 * 76 * @param {DebuggerFSMContext} debuggerFSMContext - The FSM context. 77 */ 78 constructor(debuggerFSMContext) { 79 this.debuggerFSMContext = debuggerFSMContext; 80 } 81 82 /** 83 * Handle the "Continue" button click to test the next tracker. 84 */ 85 onTestNextTracker() { 86 throw new Error("onTestNextTracker must be implemented in derived class"); 87 } 88 89 /** 90 * Handle the "Website Broke" button click to unblock the last tracker. 91 */ 92 onWebsiteBroke() { 93 throw new Error("onWebsiteBroke must be implemented in derived class"); 94 } 95 } 96 97 /** 98 * This stage groups trackers by their top-level domain and allows the user to test 99 * each domain group separately. If a website breaks, the user can use `onWebsiteBroke` 100 * to unblock the last group and continue testing the next domain group. 101 * If the website is not broken, the user can use `onTestNextTracker` to test the next domain group. 102 * If all groups are tested, it transitions to the individual domain stage. 103 */ 104 class DomainStageState extends FSMState { 105 constructor(debuggerFSMContext) { 106 super(debuggerFSMContext); 107 this.domainGroups = this.groupByDomain(debuggerFSMContext.allTrackers); 108 this.lastGroup = null; 109 this.debuggerFSMContext.onPromptTextUpdate( 110 this.domainGroups.length, 111 "Click 'Continue' to start domain debugging." 112 ); 113 this.debuggerFSMContext.onButtonStateUpdate("test-next-tracker", false); 114 } 115 116 /** 117 * Handle the "Continue" button click to test the next domain group. 118 */ 119 async onTestNextTracker() { 120 this.lastGroup = this.domainGroups.shift(); 121 const count = this.domainGroups.length; 122 // If no more domain groups to check, transition to subdomain stage 123 if (!this.lastGroup) { 124 this.debuggerFSMContext.onPromptTextUpdate( 125 count, 126 "Domain debugging finished. Starting subdomain tracker stage. Click 'Continue' to proceed." 127 ); 128 this.debuggerFSMContext.onButtonStateUpdate("website-broke", true); 129 this.debuggerFSMContext.changeState( 130 new SubdomainStageState(this.debuggerFSMContext) 131 ); 132 return; 133 } 134 await this.debuggerFSMContext.onTrackersBlockedStateUpdate( 135 true, 136 this.lastGroup.hosts 137 ); 138 this.debuggerFSMContext.onButtonStateUpdate("website-broke", false); 139 this.debuggerFSMContext.onPromptTextUpdate( 140 count, 141 `Blocked domain group '${this.lastGroup.domain}'. If the website is broken, click 'Website Broke', otherwise 'Continue'.` 142 ); 143 } 144 145 /** 146 * Handle the "Website Broke" button click to unblock the last group. 147 */ 148 async onWebsiteBroke() { 149 if (this.lastGroup && this.lastGroup.hosts) { 150 this.lastGroup.hosts.forEach(tracker => 151 this.debuggerFSMContext.subdomainStageTrackers.add(tracker) 152 ); 153 // Unblock the group to restore site 154 await this.debuggerFSMContext.onTrackersBlockedStateUpdate( 155 false, 156 this.lastGroup.hosts 157 ); 158 const count = this.domainGroups.length; 159 this.debuggerFSMContext.onPromptTextUpdate( 160 count, 161 `Domain group '${this.lastGroup.domain}' will be tested individually later. Click 'Continue' to test the next domain group.` 162 ); 163 this.debuggerFSMContext.onButtonStateUpdate("website-broke", true); 164 } 165 } 166 167 /** 168 * Group trackers by their top-level domain. 169 * 170 * @param {string[]} trackers - List of tracker hostnames. 171 */ 172 groupByDomain(trackers) { 173 const domainGroupsMap = {}; 174 trackers.forEach(tracker => { 175 const domain = Services.eTLD.getBaseDomainFromHost(tracker); 176 if (!domainGroupsMap[domain]) { 177 domainGroupsMap[domain] = new Set(); 178 } 179 domainGroupsMap[domain].add(tracker); 180 }); 181 return Object.entries(domainGroupsMap).map(([domain, hosts]) => ({ 182 domain, 183 hosts: Array.from(hosts), 184 })); 185 } 186 } 187 188 /** 189 * Subdomain stage: block/unblock each tracker separately. This stage is very similar to the domain stage, 190 * but it allows the user to test each subdomain tracker individually. This ensures we can identify 191 * which specific subdomain tracker is causing issues, rather than just the top-level domain. 192 */ 193 class SubdomainStageState extends FSMState { 194 constructor(debuggerFSMContext) { 195 super(debuggerFSMContext); 196 this.subdomains = Array.from(debuggerFSMContext.subdomainStageTrackers); 197 this.lastSubdomain = null; 198 } 199 200 async onTestNextTracker() { 201 this.lastSubdomain = this.subdomains.shift(); 202 const count = this.subdomains.length; 203 if (!this.lastSubdomain) { 204 this.debuggerFSMContext.changeState( 205 new CompletedState(this.debuggerFSMContext) 206 ); 207 return; 208 } 209 await this.debuggerFSMContext.onTrackersBlockedStateUpdate(true, [ 210 this.lastSubdomain, 211 ]); 212 this.debuggerFSMContext.onButtonStateUpdate("website-broke", false); 213 this.debuggerFSMContext.onPromptTextUpdate( 214 count, 215 `Blocked subdomain '${this.lastSubdomain}'. If the website is broken, click 'Website Broke', otherwise 'Continue'.` 216 ); 217 } 218 219 async onWebsiteBroke() { 220 if (this.lastSubdomain) { 221 this.debuggerFSMContext.necessaryTrackers.add(this.lastSubdomain); 222 await this.debuggerFSMContext.onTrackersBlockedStateUpdate(false, [ 223 this.lastSubdomain, 224 ]); 225 const count = this.subdomains.length; 226 this.debuggerFSMContext.onButtonStateUpdate("website-broke", true); 227 this.debuggerFSMContext.onPromptTextUpdate( 228 count, 229 `Added '${this.lastSubdomain}' to necessary trackers. Click 'Continue' to test the next subdomain.` 230 ); 231 } 232 } 233 } 234 235 class CompletedState extends FSMState { 236 constructor(debuggerFSMContext) { 237 super(debuggerFSMContext); 238 this.debuggerFSMContext.onPromptTextUpdate( 239 undefined, 240 `Subdomain debugging finished. Please add the following to the exceptions list: ${Array.from(this.debuggerFSMContext.necessaryTrackers).join(", ")}`, 241 true 242 ); 243 this.debuggerFSMContext.onButtonStateUpdate("test-next-tracker", true); 244 this.debuggerFSMContext.onButtonStateUpdate("website-broke", true); 245 this.debuggerFSMContext.onButtonStateUpdate("stop-debugging", true); 246 } 247 } 248 249 // Only export for Node.js (test) environments 250 if (typeof module !== "undefined" && module.exports) { 251 module.exports = { 252 DebuggerFSMContext, 253 DomainStageState, 254 SubdomainStageState, 255 CompletedState, 256 }; 257 } 258 259 exports.DebuggerFSMContext = DebuggerFSMContext;