@duckduckgo/autoconsent 14.82.0 → 14.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -107,6 +107,7 @@ shadow root or same-origin iframe.
107
107
  - **Paywalls do not need to be handled.** If the website presents the choice to pay or agree to cookies, the correct solution is to disable the feature on that site, so no code changes required in this case.
108
108
  - If the pop-up has an explicit "reject"-like button, you should first consider why HEURISTIC rule didn't handle it. A fix to the heuristic rule is always preferred to a new rule, as long as it doesn't cause potential false-positives on other sites.
109
109
  - **Watch out for race conditions**. A common pitfall is that a rule starts clicking before JS handlers are ready. If you detect this, add an appropriate wait step before the click, preferably based on a specific DOM state. Unconditional `wait` is a LAST RESORT because it leads to a poor UX.
110
+ - **Watch out for false positive detections**. Always verify that the rule does NOT match after the popup is dismissed and the page is reloaded. Over-detection can lead to reload loops.
110
111
  - **selfTests are optional.** It is okay to NOT have a self-test, or have it failing as long as the popup is handled correctly. Confirm this with screenshots.
111
112
  - If you cover a new CMP or a new flavor of the existing CMP, ALWAYS try to look for more examples of that case, and add to the spec file.
112
113
  - `detectCmp` and `detectPopup` must be fast. Do NOT use waiting steps — the engine retries automatically.
@@ -157,4 +158,5 @@ After creating or modifying a rule:
157
158
  2. `npm run rule-syntax-check` — validate rule JSON against schema
158
159
  3. `npx playwright test tests/<name>.spec.ts` — run the E2E test
159
160
  4. `npm run prepublish` — full build including extension bundle
160
- 5. Check the rule works across geographic regions using available regional-testing tooling.
161
+ 5. Validate that the rule stops matching after the popup is dismissed and the page is reloaded (unless it's a cosmetic rule).
162
+ 6. Check the rule works across geographic regions using available regional-testing tooling.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # v14.83.0 (Sat May 16 2026)
2
+
3
+ #### 🚀 Enhancement
4
+
5
+ - Add a hint about reload loops [#1352](https://github.com/duckduckgo/autoconsent/pull/1352) ([@muodov](https://github.com/muodov))
6
+ - Use MutationObserver in waitForPopup to avoid premature timeout [#1207](https://github.com/duckduckgo/autoconsent/pull/1207) ([@cursoragent](https://github.com/cursoragent) [@muodov](https://github.com/muodov))
7
+
8
+ #### Authors: 2
9
+
10
+ - Cursor Agent ([@cursoragent](https://github.com/cursoragent))
11
+ - Maxim Tsoy ([@muodov](https://github.com/muodov))
12
+
13
+ ---
14
+
1
15
  # v14.82.0 (Fri May 15 2026)
2
16
 
3
17
  #### 🚀 Enhancement
@@ -697,6 +697,7 @@
697
697
  enableGeneratedRules: true,
698
698
  enableHeuristicDetection: false,
699
699
  enableHeuristicAction: false,
700
+ enablePopupMutationObserver: false,
700
701
  detectRetries: 20,
701
702
  isMainWorld: false,
702
703
  prehideTimeout: 2e3,
@@ -726,6 +727,12 @@
726
727
  if (!storedConfig.enableHeuristicDetection) {
727
728
  storedConfig.enableHeuristicDetection = true;
728
729
  }
730
+ if (storedConfig.enablePopupMutationObserver === void 0) {
731
+ storedConfig.enablePopupMutationObserver = true;
732
+ }
733
+ if (storedConfig.enableHeuristicAction === void 0) {
734
+ storedConfig.enableHeuristicAction = true;
735
+ }
729
736
  if (!storedConfig.logs) {
730
737
  storedConfig.logs = {
731
738
  lifecycle: true,
@@ -693,6 +693,7 @@
693
693
  enableGeneratedRules: true,
694
694
  enableHeuristicDetection: false,
695
695
  enableHeuristicAction: false,
696
+ enablePopupMutationObserver: false,
696
697
  detectRetries: 20,
697
698
  isMainWorld: false,
698
699
  prehideTimeout: 2e3,
@@ -3695,16 +3696,29 @@
3695
3696
  this.updateState({ selfTest: selfTestResult });
3696
3697
  return selfTestResult;
3697
3698
  }
3698
- // TODO: use MutationObserver like in findCmp()
3699
3699
  async waitForPopup(cmp, retries = 10, interval = 500) {
3700
3700
  const logsConfig = this.config.logs;
3701
3701
  logsConfig.lifecycle && console.log("checking if popup is open...", cmp.name);
3702
+ let mutationObserver = null;
3703
+ if (this.config.enablePopupMutationObserver) {
3704
+ mutationObserver = this.domActions.waitForMutation("html", 1e4);
3705
+ mutationObserver.catch(() => {
3706
+ });
3707
+ }
3702
3708
  const isOpen = await cmp.detectPopup().catch((e) => {
3703
3709
  logsConfig.errors && console.warn(`error detecting popup for ${cmp.name}`, e);
3704
3710
  return false;
3705
3711
  });
3706
3712
  if (!isOpen && retries > 0) {
3707
- await this.domActions.wait(interval);
3713
+ if (mutationObserver) {
3714
+ try {
3715
+ await Promise.all([this.domActions.wait(interval), mutationObserver]);
3716
+ } catch (e) {
3717
+ logsConfig.lifecycle && console.log(cmp.name, "popup detection timed out waiting for DOM mutation");
3718
+ }
3719
+ } else {
3720
+ await this.domActions.wait(interval);
3721
+ }
3708
3722
  return this.waitForPopup(cmp, retries - 1, interval);
3709
3723
  }
3710
3724
  logsConfig.lifecycle && console.log(cmp.name, `popup is ${isOpen ? "open" : "not open"}`);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Autoconsent",
4
- "version": "2026.5.14",
4
+ "version": "2026.5.15",
5
5
  "background": {
6
6
  "scripts": [
7
7
  "background.bundle.js"
@@ -524,6 +524,7 @@
524
524
  enableGeneratedRules: true,
525
525
  enableHeuristicDetection: false,
526
526
  enableHeuristicAction: false,
527
+ enablePopupMutationObserver: false,
527
528
  detectRetries: 20,
528
529
  isMainWorld: false,
529
530
  prehideTimeout: 2e3,
@@ -553,6 +554,12 @@
553
554
  if (!storedConfig.enableHeuristicDetection) {
554
555
  storedConfig.enableHeuristicDetection = true;
555
556
  }
557
+ if (storedConfig.enablePopupMutationObserver === void 0) {
558
+ storedConfig.enablePopupMutationObserver = true;
559
+ }
560
+ if (storedConfig.enableHeuristicAction === void 0) {
561
+ storedConfig.enableHeuristicAction = true;
562
+ }
556
563
  if (!storedConfig.logs) {
557
564
  storedConfig.logs = {
558
565
  lifecycle: true,
@@ -654,6 +661,8 @@
654
661
  const heuristicActionOffRadio = document.querySelector("input#heuristic-action-off");
655
662
  const visualTestOnRadio = document.querySelector("input#visual-test-on");
656
663
  const visualTestOffRadio = document.querySelector("input#visual-test-off");
664
+ const popupMutationOnRadio = document.querySelector("input#popup-mutation-on");
665
+ const popupMutationOffRadio = document.querySelector("input#popup-mutation-off");
657
666
  const retriesInput = document.querySelector("input#retries");
658
667
  const logsLifecycleCheckbox = document.querySelector("input#logs-lifecycle");
659
668
  const logsRulestepsCheckbox = document.querySelector("input#logs-rulesteps");
@@ -739,6 +748,11 @@
739
748
  } else {
740
749
  visualTestOffRadio.checked = true;
741
750
  }
751
+ if (autoconsentConfig.enablePopupMutationObserver) {
752
+ popupMutationOnRadio.checked = true;
753
+ } else {
754
+ popupMutationOffRadio.checked = true;
755
+ }
742
756
  enabledCheckbox.addEventListener("change", async () => {
743
757
  await setIsEnabledForDomain(currentDomain, enabledCheckbox.checked);
744
758
  });
@@ -789,6 +803,12 @@
789
803
  }
790
804
  visualTestOnRadio.addEventListener("change", visualTestChange);
791
805
  visualTestOffRadio.addEventListener("change", visualTestChange);
806
+ function popupMutationChange() {
807
+ autoconsentConfig.enablePopupMutationObserver = popupMutationOnRadio.checked;
808
+ storageSet({ config: autoconsentConfig });
809
+ }
810
+ popupMutationOnRadio.addEventListener("change", popupMutationChange);
811
+ popupMutationOffRadio.addEventListener("change", popupMutationChange);
792
812
  function updateLogsConfig() {
793
813
  autoconsentConfig.logs = {
794
814
  lifecycle: logsLifecycleCheckbox.checked,
@@ -123,6 +123,20 @@
123
123
  </div>
124
124
  </fieldset>
125
125
 
126
+ <fieldset>
127
+ <legend>Popup mutation observer</legend>
128
+
129
+ <div>
130
+ <input type="radio" id="popup-mutation-on" name="popup-mutation" value="true" />
131
+ <label for="popup-mutation-on">On</label>
132
+ </div>
133
+
134
+ <div>
135
+ <input type="radio" id="popup-mutation-off" name="popup-mutation" value="false" checked />
136
+ <label for="popup-mutation-off">Off</label>
137
+ </div>
138
+ </fieldset>
139
+
126
140
  <fieldset>
127
141
  <legend>Debug logging</legend>
128
142
  <div>
@@ -697,6 +697,7 @@
697
697
  enableGeneratedRules: true,
698
698
  enableHeuristicDetection: false,
699
699
  enableHeuristicAction: false,
700
+ enablePopupMutationObserver: false,
700
701
  detectRetries: 20,
701
702
  isMainWorld: false,
702
703
  prehideTimeout: 2e3,
@@ -726,6 +727,12 @@
726
727
  if (!storedConfig.enableHeuristicDetection) {
727
728
  storedConfig.enableHeuristicDetection = true;
728
729
  }
730
+ if (storedConfig.enablePopupMutationObserver === void 0) {
731
+ storedConfig.enablePopupMutationObserver = true;
732
+ }
733
+ if (storedConfig.enableHeuristicAction === void 0) {
734
+ storedConfig.enableHeuristicAction = true;
735
+ }
729
736
  if (!storedConfig.logs) {
730
737
  storedConfig.logs = {
731
738
  lifecycle: true,
@@ -693,6 +693,7 @@
693
693
  enableGeneratedRules: true,
694
694
  enableHeuristicDetection: false,
695
695
  enableHeuristicAction: false,
696
+ enablePopupMutationObserver: false,
696
697
  detectRetries: 20,
697
698
  isMainWorld: false,
698
699
  prehideTimeout: 2e3,
@@ -3695,16 +3696,29 @@
3695
3696
  this.updateState({ selfTest: selfTestResult });
3696
3697
  return selfTestResult;
3697
3698
  }
3698
- // TODO: use MutationObserver like in findCmp()
3699
3699
  async waitForPopup(cmp, retries = 10, interval = 500) {
3700
3700
  const logsConfig = this.config.logs;
3701
3701
  logsConfig.lifecycle && console.log("checking if popup is open...", cmp.name);
3702
+ let mutationObserver = null;
3703
+ if (this.config.enablePopupMutationObserver) {
3704
+ mutationObserver = this.domActions.waitForMutation("html", 1e4);
3705
+ mutationObserver.catch(() => {
3706
+ });
3707
+ }
3702
3708
  const isOpen = await cmp.detectPopup().catch((e) => {
3703
3709
  logsConfig.errors && console.warn(`error detecting popup for ${cmp.name}`, e);
3704
3710
  return false;
3705
3711
  });
3706
3712
  if (!isOpen && retries > 0) {
3707
- await this.domActions.wait(interval);
3713
+ if (mutationObserver) {
3714
+ try {
3715
+ await Promise.all([this.domActions.wait(interval), mutationObserver]);
3716
+ } catch (e) {
3717
+ logsConfig.lifecycle && console.log(cmp.name, "popup detection timed out waiting for DOM mutation");
3718
+ }
3719
+ } else {
3720
+ await this.domActions.wait(interval);
3721
+ }
3708
3722
  return this.waitForPopup(cmp, retries - 1, interval);
3709
3723
  }
3710
3724
  logsConfig.lifecycle && console.log(cmp.name, `popup is ${isOpen ? "open" : "not open"}`);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Autoconsent",
4
- "version": "2026.5.14",
4
+ "version": "2026.5.15",
5
5
  "background": {
6
6
  "service_worker": "background.bundle.js"
7
7
  },
@@ -524,6 +524,7 @@
524
524
  enableGeneratedRules: true,
525
525
  enableHeuristicDetection: false,
526
526
  enableHeuristicAction: false,
527
+ enablePopupMutationObserver: false,
527
528
  detectRetries: 20,
528
529
  isMainWorld: false,
529
530
  prehideTimeout: 2e3,
@@ -553,6 +554,12 @@
553
554
  if (!storedConfig.enableHeuristicDetection) {
554
555
  storedConfig.enableHeuristicDetection = true;
555
556
  }
557
+ if (storedConfig.enablePopupMutationObserver === void 0) {
558
+ storedConfig.enablePopupMutationObserver = true;
559
+ }
560
+ if (storedConfig.enableHeuristicAction === void 0) {
561
+ storedConfig.enableHeuristicAction = true;
562
+ }
556
563
  if (!storedConfig.logs) {
557
564
  storedConfig.logs = {
558
565
  lifecycle: true,
@@ -654,6 +661,8 @@
654
661
  const heuristicActionOffRadio = document.querySelector("input#heuristic-action-off");
655
662
  const visualTestOnRadio = document.querySelector("input#visual-test-on");
656
663
  const visualTestOffRadio = document.querySelector("input#visual-test-off");
664
+ const popupMutationOnRadio = document.querySelector("input#popup-mutation-on");
665
+ const popupMutationOffRadio = document.querySelector("input#popup-mutation-off");
657
666
  const retriesInput = document.querySelector("input#retries");
658
667
  const logsLifecycleCheckbox = document.querySelector("input#logs-lifecycle");
659
668
  const logsRulestepsCheckbox = document.querySelector("input#logs-rulesteps");
@@ -739,6 +748,11 @@
739
748
  } else {
740
749
  visualTestOffRadio.checked = true;
741
750
  }
751
+ if (autoconsentConfig.enablePopupMutationObserver) {
752
+ popupMutationOnRadio.checked = true;
753
+ } else {
754
+ popupMutationOffRadio.checked = true;
755
+ }
742
756
  enabledCheckbox.addEventListener("change", async () => {
743
757
  await setIsEnabledForDomain(currentDomain, enabledCheckbox.checked);
744
758
  });
@@ -789,6 +803,12 @@
789
803
  }
790
804
  visualTestOnRadio.addEventListener("change", visualTestChange);
791
805
  visualTestOffRadio.addEventListener("change", visualTestChange);
806
+ function popupMutationChange() {
807
+ autoconsentConfig.enablePopupMutationObserver = popupMutationOnRadio.checked;
808
+ storageSet({ config: autoconsentConfig });
809
+ }
810
+ popupMutationOnRadio.addEventListener("change", popupMutationChange);
811
+ popupMutationOffRadio.addEventListener("change", popupMutationChange);
792
812
  function updateLogsConfig() {
793
813
  autoconsentConfig.logs = {
794
814
  lifecycle: logsLifecycleCheckbox.checked,
@@ -123,6 +123,20 @@
123
123
  </div>
124
124
  </fieldset>
125
125
 
126
+ <fieldset>
127
+ <legend>Popup mutation observer</legend>
128
+
129
+ <div>
130
+ <input type="radio" id="popup-mutation-on" name="popup-mutation" value="true" />
131
+ <label for="popup-mutation-on">On</label>
132
+ </div>
133
+
134
+ <div>
135
+ <input type="radio" id="popup-mutation-off" name="popup-mutation" value="false" checked />
136
+ <label for="popup-mutation-off">Off</label>
137
+ </div>
138
+ </fieldset>
139
+
126
140
  <fieldset>
127
141
  <legend>Debug logging</legend>
128
142
  <div>
@@ -728,6 +728,7 @@ function normalizeConfig(providedConfig) {
728
728
  enableGeneratedRules: true,
729
729
  enableHeuristicDetection: false,
730
730
  enableHeuristicAction: false,
731
+ enablePopupMutationObserver: false,
731
732
  detectRetries: 20,
732
733
  isMainWorld: false,
733
734
  prehideTimeout: 2e3,
@@ -3963,16 +3964,29 @@ var AutoConsent = class {
3963
3964
  this.updateState({ selfTest: selfTestResult });
3964
3965
  return selfTestResult;
3965
3966
  }
3966
- // TODO: use MutationObserver like in findCmp()
3967
3967
  async waitForPopup(cmp, retries = 10, interval = 500) {
3968
3968
  const logsConfig = this.config.logs;
3969
3969
  logsConfig.lifecycle && console.log("checking if popup is open...", cmp.name);
3970
+ let mutationObserver = null;
3971
+ if (this.config.enablePopupMutationObserver) {
3972
+ mutationObserver = this.domActions.waitForMutation("html", 1e4);
3973
+ mutationObserver.catch(() => {
3974
+ });
3975
+ }
3970
3976
  const isOpen = await cmp.detectPopup().catch((e) => {
3971
3977
  logsConfig.errors && console.warn(`error detecting popup for ${cmp.name}`, e);
3972
3978
  return false;
3973
3979
  });
3974
3980
  if (!isOpen && retries > 0) {
3975
- await this.domActions.wait(interval);
3981
+ if (mutationObserver) {
3982
+ try {
3983
+ await Promise.all([this.domActions.wait(interval), mutationObserver]);
3984
+ } catch (e) {
3985
+ logsConfig.lifecycle && console.log(cmp.name, "popup detection timed out waiting for DOM mutation");
3986
+ }
3987
+ } else {
3988
+ await this.domActions.wait(interval);
3989
+ }
3976
3990
  return this.waitForPopup(cmp, retries - 1, interval);
3977
3991
  }
3978
3992
  logsConfig.lifecycle && console.log(cmp.name, `popup is ${isOpen ? "open" : "not open"}`);
@@ -698,6 +698,7 @@ function normalizeConfig(providedConfig) {
698
698
  enableGeneratedRules: true,
699
699
  enableHeuristicDetection: false,
700
700
  enableHeuristicAction: false,
701
+ enablePopupMutationObserver: false,
701
702
  detectRetries: 20,
702
703
  isMainWorld: false,
703
704
  prehideTimeout: 2e3,
@@ -3933,16 +3934,29 @@ var AutoConsent = class {
3933
3934
  this.updateState({ selfTest: selfTestResult });
3934
3935
  return selfTestResult;
3935
3936
  }
3936
- // TODO: use MutationObserver like in findCmp()
3937
3937
  async waitForPopup(cmp, retries = 10, interval = 500) {
3938
3938
  const logsConfig = this.config.logs;
3939
3939
  logsConfig.lifecycle && console.log("checking if popup is open...", cmp.name);
3940
+ let mutationObserver = null;
3941
+ if (this.config.enablePopupMutationObserver) {
3942
+ mutationObserver = this.domActions.waitForMutation("html", 1e4);
3943
+ mutationObserver.catch(() => {
3944
+ });
3945
+ }
3940
3946
  const isOpen = await cmp.detectPopup().catch((e) => {
3941
3947
  logsConfig.errors && console.warn(`error detecting popup for ${cmp.name}`, e);
3942
3948
  return false;
3943
3949
  });
3944
3950
  if (!isOpen && retries > 0) {
3945
- await this.domActions.wait(interval);
3951
+ if (mutationObserver) {
3952
+ try {
3953
+ await Promise.all([this.domActions.wait(interval), mutationObserver]);
3954
+ } catch (e) {
3955
+ logsConfig.lifecycle && console.log(cmp.name, "popup detection timed out waiting for DOM mutation");
3956
+ }
3957
+ } else {
3958
+ await this.domActions.wait(interval);
3959
+ }
3946
3960
  return this.waitForPopup(cmp, retries - 1, interval);
3947
3961
  }
3948
3962
  logsConfig.lifecycle && console.log(cmp.name, `popup is ${isOpen ? "open" : "not open"}`);
@@ -11291,6 +11291,7 @@ function normalizeConfig(providedConfig) {
11291
11291
  enableGeneratedRules: true,
11292
11292
  enableHeuristicDetection: false,
11293
11293
  enableHeuristicAction: false,
11294
+ enablePopupMutationObserver: false,
11294
11295
  detectRetries: 20,
11295
11296
  isMainWorld: false,
11296
11297
  prehideTimeout: 2e3,
@@ -14961,16 +14962,29 @@ var AutoConsent = class {
14961
14962
  this.updateState({ selfTest: selfTestResult });
14962
14963
  return selfTestResult;
14963
14964
  }
14964
- // TODO: use MutationObserver like in findCmp()
14965
14965
  async waitForPopup(cmp, retries = 10, interval = 500) {
14966
14966
  const logsConfig = this.config.logs;
14967
14967
  logsConfig.lifecycle && console.log("checking if popup is open...", cmp.name);
14968
+ let mutationObserver = null;
14969
+ if (this.config.enablePopupMutationObserver) {
14970
+ mutationObserver = this.domActions.waitForMutation("html", 1e4);
14971
+ mutationObserver.catch(() => {
14972
+ });
14973
+ }
14968
14974
  const isOpen = await cmp.detectPopup().catch((e) => {
14969
14975
  logsConfig.errors && console.warn(`error detecting popup for ${cmp.name}`, e);
14970
14976
  return false;
14971
14977
  });
14972
14978
  if (!isOpen && retries > 0) {
14973
- await this.domActions.wait(interval);
14979
+ if (mutationObserver) {
14980
+ try {
14981
+ await Promise.all([this.domActions.wait(interval), mutationObserver]);
14982
+ } catch (e) {
14983
+ logsConfig.lifecycle && console.log(cmp.name, "popup detection timed out waiting for DOM mutation");
14984
+ }
14985
+ } else {
14986
+ await this.domActions.wait(interval);
14987
+ }
14974
14988
  return this.waitForPopup(cmp, retries - 1, interval);
14975
14989
  }
14976
14990
  logsConfig.lifecycle && console.log(cmp.name, `popup is ${isOpen ? "open" : "not open"}`);
@@ -11225,6 +11225,7 @@ function normalizeConfig(providedConfig) {
11225
11225
  enableGeneratedRules: true,
11226
11226
  enableHeuristicDetection: false,
11227
11227
  enableHeuristicAction: false,
11228
+ enablePopupMutationObserver: false,
11228
11229
  detectRetries: 20,
11229
11230
  isMainWorld: false,
11230
11231
  prehideTimeout: 2e3,
@@ -14895,16 +14896,29 @@ var AutoConsent = class {
14895
14896
  this.updateState({ selfTest: selfTestResult });
14896
14897
  return selfTestResult;
14897
14898
  }
14898
- // TODO: use MutationObserver like in findCmp()
14899
14899
  async waitForPopup(cmp, retries = 10, interval = 500) {
14900
14900
  const logsConfig = this.config.logs;
14901
14901
  logsConfig.lifecycle && console.log("checking if popup is open...", cmp.name);
14902
+ let mutationObserver = null;
14903
+ if (this.config.enablePopupMutationObserver) {
14904
+ mutationObserver = this.domActions.waitForMutation("html", 1e4);
14905
+ mutationObserver.catch(() => {
14906
+ });
14907
+ }
14902
14908
  const isOpen = await cmp.detectPopup().catch((e) => {
14903
14909
  logsConfig.errors && console.warn(`error detecting popup for ${cmp.name}`, e);
14904
14910
  return false;
14905
14911
  });
14906
14912
  if (!isOpen && retries > 0) {
14907
- await this.domActions.wait(interval);
14913
+ if (mutationObserver) {
14914
+ try {
14915
+ await Promise.all([this.domActions.wait(interval), mutationObserver]);
14916
+ } catch (e) {
14917
+ logsConfig.lifecycle && console.log(cmp.name, "popup detection timed out waiting for DOM mutation");
14918
+ }
14919
+ } else {
14920
+ await this.domActions.wait(interval);
14921
+ }
14908
14922
  return this.waitForPopup(cmp, retries - 1, interval);
14909
14923
  }
14910
14924
  logsConfig.lifecycle && console.log(cmp.name, `popup is ${isOpen ? "open" : "not open"}`);
@@ -700,6 +700,7 @@
700
700
  enableGeneratedRules: true,
701
701
  enableHeuristicDetection: false,
702
702
  enableHeuristicAction: false,
703
+ enablePopupMutationObserver: false,
703
704
  detectRetries: 20,
704
705
  isMainWorld: false,
705
706
  prehideTimeout: 2e3,
@@ -3703,16 +3704,29 @@
3703
3704
  this.updateState({ selfTest: selfTestResult });
3704
3705
  return selfTestResult;
3705
3706
  }
3706
- // TODO: use MutationObserver like in findCmp()
3707
3707
  async waitForPopup(cmp, retries = 10, interval = 500) {
3708
3708
  const logsConfig = this.config.logs;
3709
3709
  logsConfig.lifecycle && console.log("checking if popup is open...", cmp.name);
3710
+ let mutationObserver = null;
3711
+ if (this.config.enablePopupMutationObserver) {
3712
+ mutationObserver = this.domActions.waitForMutation("html", 1e4);
3713
+ mutationObserver.catch(() => {
3714
+ });
3715
+ }
3710
3716
  const isOpen = await cmp.detectPopup().catch((e) => {
3711
3717
  logsConfig.errors && console.warn(`error detecting popup for ${cmp.name}`, e);
3712
3718
  return false;
3713
3719
  });
3714
3720
  if (!isOpen && retries > 0) {
3715
- await this.domActions.wait(interval);
3721
+ if (mutationObserver) {
3722
+ try {
3723
+ await Promise.all([this.domActions.wait(interval), mutationObserver]);
3724
+ } catch (e) {
3725
+ logsConfig.lifecycle && console.log(cmp.name, "popup detection timed out waiting for DOM mutation");
3726
+ }
3727
+ } else {
3728
+ await this.domActions.wait(interval);
3729
+ }
3716
3730
  return this.waitForPopup(cmp, retries - 1, interval);
3717
3731
  }
3718
3732
  logsConfig.lifecycle && console.log(cmp.name, `popup is ${isOpen ? "open" : "not open"}`);
@@ -61,6 +61,7 @@ export type Config = {
61
61
  enableFilterList: boolean;
62
62
  enableHeuristicDetection: boolean;
63
63
  enableHeuristicAction: boolean;
64
+ enablePopupMutationObserver: boolean;
64
65
  visualTest: boolean;
65
66
  logs: {
66
67
  lifecycle: boolean;
package/lib/types.ts CHANGED
@@ -65,6 +65,7 @@ export type Config = {
65
65
  enableFilterList: boolean;
66
66
  enableHeuristicDetection: boolean;
67
67
  enableHeuristicAction: boolean;
68
+ enablePopupMutationObserver: boolean;
68
69
  visualTest: boolean; // If true, the script will delay before every click action
69
70
  logs: {
70
71
  lifecycle: boolean;
package/lib/utils.ts CHANGED
@@ -78,6 +78,7 @@ export function normalizeConfig(providedConfig: any): Config {
78
78
  enableGeneratedRules: true,
79
79
  enableHeuristicDetection: false,
80
80
  enableHeuristicAction: false,
81
+ enablePopupMutationObserver: false,
81
82
  detectRetries: 20,
82
83
  isMainWorld: false,
83
84
  prehideTimeout: 2000,
package/lib/web.ts CHANGED
@@ -553,16 +553,30 @@ export default class AutoConsent {
553
553
  return selfTestResult;
554
554
  }
555
555
 
556
- // TODO: use MutationObserver like in findCmp()
557
556
  async waitForPopup(cmp: AutoCMP, retries = 10, interval = 500): Promise<boolean> {
558
557
  const logsConfig = this.config.logs;
559
558
  logsConfig.lifecycle && console.log('checking if popup is open...', cmp.name);
559
+
560
+ let mutationObserver: Promise<boolean> | null = null;
561
+ if (this.config.enablePopupMutationObserver) {
562
+ mutationObserver = this.domActions.waitForMutation('html', 10000);
563
+ mutationObserver.catch(() => {});
564
+ }
565
+
560
566
  const isOpen = await cmp.detectPopup().catch((e) => {
561
567
  logsConfig.errors && console.warn(`error detecting popup for ${cmp.name}`, e);
562
568
  return false;
563
- }); // ignore possible errors in one-time popup detection
569
+ });
564
570
  if (!isOpen && retries > 0) {
565
- await this.domActions.wait(interval);
571
+ if (mutationObserver) {
572
+ try {
573
+ await Promise.all([this.domActions.wait(interval), mutationObserver]);
574
+ } catch (e) {
575
+ logsConfig.lifecycle && console.log(cmp.name, 'popup detection timed out waiting for DOM mutation');
576
+ }
577
+ } else {
578
+ await this.domActions.wait(interval);
579
+ }
566
580
  return this.waitForPopup(cmp, retries - 1, interval);
567
581
  }
568
582
  logsConfig.lifecycle && console.log(cmp.name, `popup is ${isOpen ? 'open' : 'not open'}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duckduckgo/autoconsent",
3
- "version": "14.82.0",
3
+ "version": "14.83.0",
4
4
  "description": "",
5
5
  "types": "./dist/types/web.d.ts",
6
6
  "exports": {
@@ -0,0 +1,14 @@
1
+ <html>
2
+ <body>
3
+ <script type="module">
4
+ import { runTests } from '@web/test-runner-mocha';
5
+
6
+ runTests(async () => {
7
+ await import('./wait-for-popup');
8
+ });
9
+ </script>
10
+ <div id="page-content">
11
+ <p>Test page for waitForPopup tests</p>
12
+ </div>
13
+ </body>
14
+ </html>
@@ -0,0 +1,214 @@
1
+ import { expect } from '@esm-bundle/chai';
2
+ import Autoconsent from '../../lib/web';
3
+ import { AutoCMP } from '../../lib/types';
4
+
5
+ function createAutoconsent(enablePopupMutationObserver: boolean): Autoconsent {
6
+ return new Autoconsent(() => Promise.resolve(), {
7
+ enabled: false,
8
+ autoAction: null,
9
+ enablePopupMutationObserver,
10
+ });
11
+ }
12
+
13
+ function createMockCmp(detectPopupFn: () => Promise<boolean>): AutoCMP {
14
+ return {
15
+ name: 'mock-cmp',
16
+ hasSelfTest: false,
17
+ isIntermediate: false,
18
+ isCosmetic: false,
19
+ runContext: { main: true, frame: false },
20
+ checkRunContext: () => true,
21
+ checkFrameContext: () => true,
22
+ hasMatchingUrlPattern: () => true,
23
+ detectCmp: () => Promise.resolve(true),
24
+ detectPopup: detectPopupFn,
25
+ optOut: () => Promise.resolve(true),
26
+ optIn: () => Promise.resolve(true),
27
+ openCmp: () => Promise.resolve(true),
28
+ test: () => Promise.resolve(true),
29
+ };
30
+ }
31
+
32
+ describe('Autoconsent.waitForPopup', () => {
33
+ describe('without mutation observer (legacy polling)', () => {
34
+ let autoconsent: Autoconsent;
35
+
36
+ beforeEach(() => {
37
+ autoconsent = createAutoconsent(false);
38
+ });
39
+
40
+ it('should resolve true if popup is detected on first check', async () => {
41
+ const cmp = createMockCmp(() => Promise.resolve(true));
42
+ const result = await autoconsent.waitForPopup(cmp, 5, 50);
43
+ expect(result).to.be.true;
44
+ });
45
+
46
+ it('should resolve false if popup is never detected', async () => {
47
+ const cmp = createMockCmp(() => Promise.resolve(false));
48
+ const result = await autoconsent.waitForPopup(cmp, 2, 50);
49
+ expect(result).to.be.false;
50
+ });
51
+
52
+ it('should retry and detect popup that appears later', async () => {
53
+ let callCount = 0;
54
+ const cmp = createMockCmp(() => {
55
+ callCount++;
56
+ return Promise.resolve(callCount >= 3);
57
+ });
58
+ const result = await autoconsent.waitForPopup(cmp, 5, 50);
59
+ expect(result).to.be.true;
60
+ expect(callCount).to.equal(3);
61
+ });
62
+
63
+ it('should handle errors in detectPopup gracefully', async () => {
64
+ const cmp = createMockCmp(() => Promise.reject(new Error('detection error')));
65
+ const result = await autoconsent.waitForPopup(cmp, 2, 50);
66
+ expect(result).to.be.false;
67
+ });
68
+
69
+ it('should not retry when retries is 0', async () => {
70
+ let callCount = 0;
71
+ const cmp = createMockCmp(() => {
72
+ callCount++;
73
+ return Promise.resolve(false);
74
+ });
75
+ const result = await autoconsent.waitForPopup(cmp, 0, 50);
76
+ expect(result).to.be.false;
77
+ expect(callCount).to.equal(1);
78
+ });
79
+ });
80
+
81
+ describe('with mutation observer', () => {
82
+ let autoconsent: Autoconsent;
83
+
84
+ beforeEach(() => {
85
+ autoconsent = createAutoconsent(true);
86
+ });
87
+
88
+ it('should resolve true if popup is detected on first check', async () => {
89
+ const cmp = createMockCmp(() => Promise.resolve(true));
90
+ const result = await autoconsent.waitForPopup(cmp, 5, 50);
91
+ expect(result).to.be.true;
92
+ });
93
+
94
+ it('should resolve false when retries are exhausted despite mutations', async () => {
95
+ const cmp = createMockCmp(() => Promise.resolve(false));
96
+
97
+ const mutationInterval = setInterval(() => {
98
+ const el = document.createElement('div');
99
+ el.className = 'test-exhaust-mutation';
100
+ document.body.appendChild(el);
101
+ }, 30);
102
+
103
+ const result = await autoconsent.waitForPopup(cmp, 2, 50);
104
+
105
+ clearInterval(mutationInterval);
106
+ document.querySelectorAll('.test-exhaust-mutation').forEach((el) => el.remove());
107
+
108
+ expect(result).to.be.false;
109
+ });
110
+
111
+ it('should retry when a DOM mutation occurs and detect popup', async () => {
112
+ let callCount = 0;
113
+ const cmp = createMockCmp(() => {
114
+ callCount++;
115
+ return Promise.resolve(callCount >= 2);
116
+ });
117
+
118
+ // schedule a DOM mutation so the observer resolves
119
+ setTimeout(() => {
120
+ const el = document.createElement('span');
121
+ el.id = 'test-mutation-element';
122
+ document.body.appendChild(el);
123
+ }, 30);
124
+
125
+ const result = await autoconsent.waitForPopup(cmp, 5, 50);
126
+ expect(result).to.be.true;
127
+ expect(callCount).to.equal(2);
128
+
129
+ document.getElementById('test-mutation-element')?.remove();
130
+ });
131
+
132
+ it('should detect popup that appears after multiple mutations', async () => {
133
+ let callCount = 0;
134
+ const cmp = createMockCmp(() => {
135
+ callCount++;
136
+ return Promise.resolve(callCount >= 3);
137
+ });
138
+
139
+ // schedule DOM mutations to keep the observer firing
140
+ const mutationInterval = setInterval(() => {
141
+ const el = document.createElement('div');
142
+ el.className = 'test-multi-mutation';
143
+ document.body.appendChild(el);
144
+ }, 40);
145
+
146
+ const result = await autoconsent.waitForPopup(cmp, 5, 50);
147
+
148
+ clearInterval(mutationInterval);
149
+ document.querySelectorAll('.test-multi-mutation').forEach((el) => el.remove());
150
+
151
+ expect(result).to.be.true;
152
+ expect(callCount).to.equal(3);
153
+ });
154
+
155
+ it('should handle errors in detectPopup gracefully', async () => {
156
+ let callCount = 0;
157
+ const cmp = createMockCmp(() => {
158
+ callCount++;
159
+ if (callCount <= 2) {
160
+ return Promise.reject(new Error('transient error'));
161
+ }
162
+ return Promise.resolve(true);
163
+ });
164
+
165
+ // schedule mutations so retries proceed
166
+ const mutationInterval = setInterval(() => {
167
+ const el = document.createElement('div');
168
+ el.className = 'test-error-mutation';
169
+ document.body.appendChild(el);
170
+ }, 30);
171
+
172
+ const result = await autoconsent.waitForPopup(cmp, 5, 50);
173
+
174
+ clearInterval(mutationInterval);
175
+ document.querySelectorAll('.test-error-mutation').forEach((el) => el.remove());
176
+
177
+ expect(result).to.be.true;
178
+ expect(callCount).to.equal(3);
179
+ });
180
+
181
+ it('should not retry when retries is 0', async () => {
182
+ let callCount = 0;
183
+ const cmp = createMockCmp(() => {
184
+ callCount++;
185
+ return Promise.resolve(false);
186
+ });
187
+ const result = await autoconsent.waitForPopup(cmp, 0, 50);
188
+ expect(result).to.be.false;
189
+ expect(callCount).to.equal(1);
190
+ });
191
+
192
+ it('should continue retrying after mutation observer timeout', async () => {
193
+ // Override waitForMutation to use a very short timeout so it rejects quickly
194
+ const originalWaitForMutation = autoconsent.domActions.waitForMutation.bind(autoconsent.domActions);
195
+ autoconsent.domActions.waitForMutation = (selector, _timeout?) => {
196
+ return originalWaitForMutation(selector, 10);
197
+ };
198
+
199
+ let callCount = 0;
200
+ const cmp = createMockCmp(() => {
201
+ callCount++;
202
+ // popup appears on the 3rd attempt (after 2 mutation timeouts)
203
+ return Promise.resolve(callCount >= 3);
204
+ });
205
+
206
+ const result = await autoconsent.waitForPopup(cmp, 5, 50);
207
+
208
+ autoconsent.domActions.waitForMutation = originalWaitForMutation;
209
+
210
+ expect(result).to.be.true;
211
+ expect(callCount).to.equal(3);
212
+ });
213
+ });
214
+ });