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
import {
selectPrivacyMessage,
CATEGORY,
PRIVACY_MESSAGES,
} from "lib/Widgets/PrivacyMessages.sys.mjs";
// A fixed Monday noon UTC so period/day math is stable across runs.
const NOW = Date.UTC(2026, 5, 15, 12, 0, 0);
const HOUR = 60 * 60 * 1000;
// Deterministic rand: returns each queued value once, then 0.
function rand(values = []) {
let i = 0;
return () => (i < values.length ? values[i++] : 0);
}
function ctx(over = {}) {
return {
trackersToday: 10,
sitesToday: 5,
weekTotal: 0,
monthTotal: 0,
yearTotal: 0,
allTimeTotal: 0,
streakDays: 1,
maxCount: 100,
blankChance: 0.4,
// Match the shipping default: VPN promos gated off. The fixture models a
// VPN-eligible user (vpnEnabled) whose promos are simply not opted in; the
// two gates are exercised explicitly in the showVpnMessages tests below.
showVpnMessages: false,
vpnEnabled: true,
profileCreatedMs: 0, // old profile -> normal caps
features: {},
...over,
};
}
// firstProtectionShown defaults true so tests aren't all caught by the
// once-ever first-protection rung.
function state(over = {}) {
return { firstProtectionShown: true, ...over };
}
function catOf(messageId) {
return PRIVACY_MESSAGES.find(m => m.id === messageId)?.category;
}
describe("selectPrivacyMessage", () => {
it("shows the empty state when no trackers blocked today", () => {
const { decision } = selectPrivacyMessage(
ctx({ trackersToday: 0 }),
state(),
NOW,
rand()
);
expect(decision.variant).toBe("empty");
});
it("still picks a message when no sites were visited today", () => {
// A zero site count is not a zero state, so the scheduler keeps running.
const { decision } = selectPrivacyMessage(
ctx({ trackersToday: 34, sitesToday: 0 }),
state(),
NOW,
rand()
);
expect(["blank", "streak", "tip"]).toContain(decision.variant);
});
it("shows first-protection once, then records it", () => {
const first = selectPrivacyMessage(
ctx(),
{ firstProtectionShown: false },
NOW,
rand()
);
expect(catOf(first.decision.messageId)).toBe(CATEGORY.FIRST_PROTECTION);
expect(first.nextState.firstProtectionShown).toBe(true);
expect(first.decision.icon).toBe("kit");
});
it("fires the streak at 3, not again at 3, then at 5", () => {
const day1 = selectPrivacyMessage(
ctx({ streakDays: 3 }),
state(),
NOW,
rand()
);
expect(day1.decision.variant).toBe("streak");
expect(day1.decision.countArg).toEqual({ count: 3 });
expect(day1.nextState.streakFiredAt).toBe(3);
// Same streak length on a later day must not refire.
const again = selectPrivacyMessage(
ctx({ streakDays: 3 }),
state({ streakFiredAt: 3, lastCelebrationDay: "2000-01-01" }),
NOW,
rand()
);
expect(again.decision.variant).not.toBe("streak");
const day5 = selectPrivacyMessage(
ctx({ streakDays: 5 }),
state({ streakFiredAt: 3, lastCelebrationDay: "2000-01-01" }),
NOW,
rand()
);
expect(day5.decision.variant).toBe("streak");
expect(day5.nextState.streakFiredAt).toBe(5);
});
it("fires a week milestone on crossing, once per period", () => {
const crossed = selectPrivacyMessage(
ctx({ weekTotal: 120 }),
state(),
NOW,
rand()
);
expect(catOf(crossed.decision.messageId)).toBe(CATEGORY.MILESTONE_WEEK);
expect(crossed.decision.countArg).toEqual({ count: 120 });
// Re-run a later day with the watermark in place: no refire.
const noRefire = selectPrivacyMessage(
ctx({ weekTotal: 140 }),
{ ...crossed.nextState, lastCelebrationDay: "2000-01-01" },
NOW,
rand()
);
expect(catOf(noRefire.decision.messageId)).not.toBe(
CATEGORY.MILESTONE_WEEK
);
});
it("picks the highest crossed month tier", () => {
const { decision } = selectPrivacyMessage(
ctx({ monthTotal: 600 }),
state(),
NOW,
rand()
);
expect(catOf(decision.messageId)).toBe(CATEGORY.MILESTONE_MONTH);
// 600 crosses 250 and 500 -> watermark advances to 500.
});
it("fires the daily-cap celebration at the cap and caps the readout", () => {
const { decision } = selectPrivacyMessage(
ctx({ trackersToday: 137, maxCount: 100 }),
state(),
NOW,
rand()
);
expect(catOf(decision.messageId)).toBe(CATEGORY.DAILY_CAP);
// Only this render caps the count display to "maxCount+".
expect(decision.countCeiling).toBe(100);
});
it("leaves countCeiling null for non-daily-cap decisions", () => {
const { decision } = selectPrivacyMessage(
ctx({ streakDays: 5 }),
state(),
NOW,
rand()
);
expect(decision.countCeiling).toBeNull();
});
it("celebrations bypass the frequency caps", () => {
const { decision } = selectPrivacyMessage(
ctx({ streakDays: 5 }),
state({ shownToday: 99, lastShownMs: NOW - 1000 }),
NOW,
rand()
);
expect(decision.variant).toBe("streak");
});
it("blanks ordinary messages within the hourly interval", () => {
const { decision } = selectPrivacyMessage(
ctx(),
state({
lastCelebrationDay: "2026-06-15",
lastShownMs: NOW - 10 * 60 * 1000,
shownToday: 1,
}),
NOW,
rand([0])
);
expect(decision.variant).toBe("blank");
});
it("blanks ordinary messages once the daily limit is hit", () => {
const { decision } = selectPrivacyMessage(
ctx(),
state({
lastCelebrationDay: "2026-06-15",
shownToday: 5,
dayStamp: "2026-06-15",
}),
NOW,
rand([0])
);
expect(decision.variant).toBe("blank");
});
it("uses the 10/day, 15-min cadence in a new profile's first 48h", () => {
const base = state({
lastCelebrationDay: "2026-06-15",
dayStamp: "2026-06-15",
});
// 5 shown + 20 min since last would blank on a normal profile...
const newProfile = selectPrivacyMessage(
ctx({ profileCreatedMs: NOW - HOUR }),
{ ...base, shownToday: 5, lastShownMs: NOW - 20 * 60 * 1000 },
NOW,
rand([0, 0.9])
);
expect(newProfile.decision.variant).toBe("tip");
});
it("rolls the 40% blank on info without consuming a slot", () => {
const base = state({
lastCelebrationDay: "2026-06-15",
dayStamp: "2026-06-15",
shownToday: 0,
});
// rand[0]=0 -> first pool item (an info message); rand[1]=0.1 < 0.4 -> blank.
const blanked = selectPrivacyMessage(ctx(), base, NOW, rand([0, 0.1]));
expect(blanked.decision.variant).toBe("blank");
expect(blanked.nextState.shownToday).toBe(0);
// rand[1]=0.9 >= 0.4 -> the info message shows and consumes a slot.
const shown = selectPrivacyMessage(ctx(), base, NOW, rand([0, 0.9]));
expect(shown.decision.variant).toBe("tip");
expect(catOf(shown.decision.messageId)).toBe(CATEGORY.INFO);
expect(shown.nextState.shownToday).toBe(1);
});
it("suppresses promos for features already in use", () => {
const base = state({
lastCelebrationDay: "2026-06-15",
dayStamp: "2026-06-15",
});
// rand near 1 selects the last pool entry. With no suppression that's a
// promo; with the logins feature in use the logins promo is filtered out.
const withLogins = selectPrivacyMessage(
ctx({ features: { signedIn: true, hasLogins: true, relayMasks: true } }),
base,
NOW,
rand([0.999, 0.9])
);
// vpn/monitor promos have no signal, so a promo can still appear; just
// assert no suppressible promo (logins/relay/signin) is shown.
const msg = PRIVACY_MESSAGES.find(
m => m.id === withLogins.decision.messageId
);
if (msg?.category === CATEGORY.PROMO) {
expect(["vpn", "monitor", "private-window"]).toContain(msg.feature);
}
});
// Sample the promo pool across the whole rand range (selection is random and
// buildPool isn't exported) and collect which promo `feature`s can appear.
const sampledPromoFeatures = context => {
const base = state({
dayStamp: "2026-06-15",
lastCelebrationDay: "2026-06-15",
});
const feats = new Set();
for (let i = 0; i < 100; i++) {
const r = i / 100;
const { decision } = selectPrivacyMessage(
context,
base,
NOW,
rand([r, r])
);
const msg = PRIVACY_MESSAGES.find(m => m.id === decision.messageId);
if (msg?.category === CATEGORY.PROMO) {
feats.add(msg.feature);
}
}
return feats;
};
it("excludes VPN promos when showVpnMessages is false", () => {
const feats = sampledPromoFeatures(ctx({ showVpnMessages: false }));
expect(feats.has("vpn")).toBe(false);
// other promos still rotate
expect(feats.size).toBeGreaterThan(0);
});
it("includes VPN promos when showVpnMessages is true", () => {
const feats = sampledPromoFeatures(ctx({ showVpnMessages: true }));
expect(feats.has("vpn")).toBe(true);
});
it("excludes VPN promos when the VPN isn't available, even if opted in", () => {
// showVpnMessages on but browser.ipProtection.enabled off -> no VPN promos.
const feats = sampledPromoFeatures(
ctx({ showVpnMessages: true, vpnEnabled: false })
);
expect(feats.has("vpn")).toBe(false);
// other promos still rotate
expect(feats.size).toBeGreaterThan(0);
});
it("limits promos to one per day", () => {
const base = state({
lastCelebrationDay: "2026-06-15",
dayStamp: "2026-06-15",
promoLastDay: "2026-06-15",
});
// Even selecting the last pool entry, promos are excluded today -> info.
const { decision } = selectPrivacyMessage(
ctx(),
base,
NOW,
rand([0.999, 0.9])
);
expect(catOf(decision.messageId)).toBe(CATEGORY.INFO);
});
it("passes the count as an l10n arg and tags the category", () => {
const { decision } = selectPrivacyMessage(
ctx({ streakDays: 7 }),
state(),
NOW,
rand()
);
expect(catOf(decision.messageId)).toBe(CATEGORY.STREAK);
expect(decision.countArg).toEqual({ count: 7 });
expect(decision.category).toBe(CATEGORY.STREAK);
});
it("carries the message's cta descriptor on the decision", () => {
const { decision } = selectPrivacyMessage(
ctx({ trackersToday: 0 }),
state(),
NOW,
rand()
);
// Empty state has no cta.
expect(decision.cta).toBeNull();
const withCta = selectPrivacyMessage(
ctx({ streakDays: 7 }),
state(),
NOW,
rand()
).decision;
const msg = PRIVACY_MESSAGES.find(m => m.id === withCta.messageId);
expect(withCta.cta).toEqual(msg.cta);
expect(withCta.cta.type).toBe("OPEN_ABOUT_PAGE");
});
it("gives the blank decision a View protections cta", () => {
// Within the hourly interval -> blank, but it still carries the protections
// action so the widget can show a CTA (the component supplies the label).
const base = state({ lastShownMs: NOW - 5 * 60 * 1000, shownToday: 1 });
const { decision } = selectPrivacyMessage(ctx(), base, NOW, rand());
expect(decision.variant).toBe("blank");
expect(decision.messageId).toBeNull();
expect(decision.cta).toEqual({
type: "OPEN_ABOUT_PAGE",
data: { args: "protections", where: "tab" },
});
});
describe("forceMessageId debug override", () => {
it("pins the widget to the requested message, bypassing the ladder", () => {
const { decision } = selectPrivacyMessage(
ctx({
trackersToday: 0, // would otherwise be the empty state
forceMessageId: "newtab-privacy-message-promo-relay-1",
}),
state(),
NOW,
rand()
);
expect(decision.messageId).toBe("newtab-privacy-message-promo-relay-1");
expect(decision.variant).toBe("tip");
expect(decision.cta.type).toBe("OPEN_URL");
});
it("forces the empty message by id", () => {
const { decision } = selectPrivacyMessage(
ctx({ forceMessageId: "newtab-privacy-empty" }),
state(),
NOW,
rand()
);
expect(decision.variant).toBe("empty");
expect(decision.messageId).toBe("newtab-privacy-empty");
});
it("does not mutate scheduler state", () => {
const prev = state({ shownToday: 3, dayStamp: "2026-06-15" });
const { nextState } = selectPrivacyMessage(
ctx({ forceMessageId: "newtab-privacy-message-info-1" }),
prev,
NOW,
rand()
);
expect(nextState.shownToday).toBe(3);
});
it("ignores an unknown id and falls through to normal scheduling", () => {
const { decision } = selectPrivacyMessage(
ctx({ trackersToday: 0, forceMessageId: "does-not-exist" }),
state(),
NOW,
rand()
);
expect(decision.variant).toBe("empty");
});
});
});
it.each([
[
"newtab-privacy-message-promo-monitor-1",
],
[
"newtab-privacy-message-promo-monitor-2",
],
[
"newtab-privacy-message-promo-relay-1",
],
[
"newtab-privacy-message-promo-relay-2",
],
[
"newtab-privacy-message-promo-relay-3",
],
])("%s opens a tagged url", (id, url) => {
const msg = PRIVACY_MESSAGES.find(m => m.id === id);
expect(msg.cta).toEqual({
type: "OPEN_URL",
data: { args: url, where: "tab" },
});
});
// The cases above name each promo explicitly, so a future Monitor/Relay promo
// could ship untagged with CI still green. This asserts the whole family stays
// tagged: every Monitor/Relay promo opens a url carrying all four utm params.
it("tags every Monitor/Relay promo CTA with all four utm params", () => {
const attributedPromos = PRIVACY_MESSAGES.filter(
m => m.feature === "monitor" || m.feature === "relay"
);
// Guard against the filter silently matching nothing (e.g. a `feature`
// rename) and the assertions below vacuously passing.
expect(attributedPromos.length).toBeGreaterThan(0);
const contents = [];
for (const msg of attributedPromos) {
expect(msg.cta.type).toBe("OPEN_URL");
const params = new URL(msg.cta.data.args).searchParams;
expect(params.get("utm_medium")).toBe("referral");
expect(params.get("utm_source")).toBe("firefox-desktop");
expect(params.get("utm_campaign")).toBe("widget");
expect(params.get("utm_content")).toBeTruthy();
contents.push(params.get("utm_content"));
}
// utm_content is what distinguishes one message's signups from another's, so
// a duplicate slug would silently merge two messages' attribution.
expect(new Set(contents).size).toBe(contents.length);
});
});