summaryrefslogtreecommitdiffstats
path: root/toolkit/components/messaging-system/targeting
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/messaging-system/targeting')
-rw-r--r--toolkit/components/messaging-system/targeting/Targeting.sys.mjs243
-rw-r--r--toolkit/components/messaging-system/targeting/test/unit/head.js6
-rw-r--r--toolkit/components/messaging-system/targeting/test/unit/test_targeting.js327
-rw-r--r--toolkit/components/messaging-system/targeting/test/unit/xpcshell.toml6
4 files changed, 582 insertions, 0 deletions
diff --git a/toolkit/components/messaging-system/targeting/Targeting.sys.mjs b/toolkit/components/messaging-system/targeting/Targeting.sys.mjs
new file mode 100644
index 0000000000..8134920b18
--- /dev/null
+++ b/toolkit/components/messaging-system/targeting/Targeting.sys.mjs
@@ -0,0 +1,243 @@
+/* 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
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ASRouterTargeting:
+ // eslint-disable-next-line mozilla/no-browser-refs-in-toolkit
+ "resource:///modules/asrouter/ASRouterTargeting.sys.mjs",
+ ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs",
+ ClientEnvironmentBase:
+ "resource://gre/modules/components-utils/ClientEnvironment.sys.mjs",
+ FilterExpressions:
+ "resource://gre/modules/components-utils/FilterExpressions.sys.mjs",
+ TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
+ clearTimeout: "resource://gre/modules/Timer.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+const TARGETING_EVENT_CATEGORY = "messaging_experiments";
+const TARGETING_EVENT_METHOD = "targeting";
+const DEFAULT_TIMEOUT = 5000;
+const ERROR_TYPES = {
+ ATTRIBUTE_ERROR: "attribute_error",
+ TIMEOUT: "attribute_timeout",
+};
+
+const TargetingEnvironment = {
+ get locale() {
+ return lazy.ASRouterTargeting.Environment.locale;
+ },
+
+ get localeLanguageCode() {
+ return lazy.ASRouterTargeting.Environment.localeLanguageCode;
+ },
+
+ get region() {
+ return lazy.ASRouterTargeting.Environment.region;
+ },
+
+ get userId() {
+ return lazy.ClientEnvironment.userId;
+ },
+
+ get version() {
+ return AppConstants.MOZ_APP_VERSION_DISPLAY;
+ },
+
+ get channel() {
+ const { settings } = lazy.TelemetryEnvironment.currentEnvironment;
+ return settings.update.channel;
+ },
+
+ get platform() {
+ return AppConstants.platform;
+ },
+
+ get os() {
+ return lazy.ClientEnvironmentBase.os;
+ },
+};
+
+export class TargetingContext {
+ #telemetrySource = null;
+
+ constructor(customContext, options = { source: null }) {
+ if (customContext) {
+ this.ctx = new Proxy(customContext, {
+ get: (customCtx, prop) => {
+ if (prop in TargetingEnvironment) {
+ return TargetingEnvironment[prop];
+ }
+ return customCtx[prop];
+ },
+ });
+ } else {
+ this.ctx = TargetingEnvironment;
+ }
+
+ // Used in telemetry to report where the targeting expression is coming from
+ this.#telemetrySource = options.source;
+
+ // Enable event recording
+ Services.telemetry.setEventRecordingEnabled(TARGETING_EVENT_CATEGORY, true);
+ }
+
+ setTelemetrySource(source) {
+ if (source) {
+ this.#telemetrySource = source;
+ }
+ }
+
+ _sendUndesiredEvent(eventData) {
+ if (this.#telemetrySource) {
+ Services.telemetry.recordEvent(
+ TARGETING_EVENT_CATEGORY,
+ TARGETING_EVENT_METHOD,
+ eventData.event,
+ eventData.value,
+ { source: this.#telemetrySource }
+ );
+ } else {
+ Services.telemetry.recordEvent(
+ TARGETING_EVENT_CATEGORY,
+ TARGETING_EVENT_METHOD,
+ eventData.event,
+ eventData.value
+ );
+ }
+ }
+
+ /**
+ * Wrap each property of context[key] with a Proxy that captures errors and
+ * timeouts
+ *
+ * @param {Object.<string, TargetingGetters> | TargetingGetters} context
+ * @param {string} key Namespace value found in `context` param
+ * @returns {TargetingGetters} Wrapped context where getter report errors and timeouts
+ */
+ createContextWithTimeout(context, key = null) {
+ const timeoutDuration = key ? context[key].timeout : context.timeout;
+ const logUndesiredEvent = (event, key, prop) => {
+ const value = key ? `${key}.${prop}` : prop;
+ this._sendUndesiredEvent({ event, value });
+ console.error(`${event}: ${value}`);
+ };
+
+ return new Proxy(context, {
+ get(target, prop) {
+ // eslint-disable-next-line no-async-promise-executor
+ return new Promise(async (resolve, reject) => {
+ // Create timeout cb to record attribute resolution taking too long.
+ let timeout = lazy.setTimeout(() => {
+ logUndesiredEvent(ERROR_TYPES.TIMEOUT, key, prop);
+ reject(
+ new Error(
+ `${prop} targeting getter timed out after ${
+ timeoutDuration || DEFAULT_TIMEOUT
+ }ms`
+ )
+ );
+ }, timeoutDuration || DEFAULT_TIMEOUT);
+
+ try {
+ resolve(await (key ? target[key][prop] : target[prop]));
+ } catch (error) {
+ logUndesiredEvent(ERROR_TYPES.ATTRIBUTE_ERROR, key, prop);
+ reject(error);
+ console.error(error);
+ } finally {
+ lazy.clearTimeout(timeout);
+ }
+ });
+ },
+ });
+ }
+
+ /**
+ * Merge all evaluation contexts and wrap the getters with timeouts
+ *
+ * @param {Object.<string, TargetingGetters>[]} contexts
+ * @returns {Object.<string, TargetingGetters>} Object that follows the pattern of `namespace: getters`
+ */
+ mergeEvaluationContexts(contexts) {
+ let context = {};
+ for (let c of contexts) {
+ for (let envNamespace of Object.keys(c)) {
+ // Take the provided context apart, replace it with a proxy
+ context[envNamespace] = this.createContextWithTimeout(c, envNamespace);
+ }
+ }
+
+ return context;
+ }
+
+ /**
+ * Merge multiple TargetingGetters objects without accidentally evaluating
+ *
+ * @param {TargetingGetters[]} ...contexts
+ * @returns {Proxy<TargetingGetters>}
+ */
+ static combineContexts(...contexts) {
+ return new Proxy(
+ {},
+ {
+ get(target, prop) {
+ for (let context of contexts) {
+ if (prop in context) {
+ return context[prop];
+ }
+ }
+
+ return null;
+ },
+ }
+ );
+ }
+
+ /**
+ * Evaluate JEXL expressions with default `TargetingEnvironment` and custom
+ * provided targeting contexts
+ *
+ * @example
+ * eval(
+ * "ctx.locale == 'en-US' && customCtx.foo == 42",
+ * { customCtx: { foo: 42 } }
+ * ); // true
+ *
+ * @param {string} expression JEXL expression
+ * @param {Object.<string, TargetingGetters>[]} ...contexts Additional custom context
+ * objects where the keys act as namespaces for the different getters
+ *
+ * @returns {promise} Evaluation result
+ */
+ eval(expression, ...contexts) {
+ return lazy.FilterExpressions.eval(
+ expression,
+ this.mergeEvaluationContexts([{ ctx: this.ctx }, ...contexts])
+ );
+ }
+
+ /**
+ * Evaluate JEXL expressions with default provided targeting context
+ *
+ * @example
+ * new TargetingContext({ bar: 42 });
+ * evalWithDefault(
+ * "bar == 42",
+ * ); // true
+ *
+ * @param {string} expression JEXL expression
+ * @returns {promise} Evaluation result
+ */
+ evalWithDefault(expression) {
+ return lazy.FilterExpressions.eval(
+ expression,
+ this.createContextWithTimeout(this.ctx)
+ );
+ }
+}
diff --git a/toolkit/components/messaging-system/targeting/test/unit/head.js b/toolkit/components/messaging-system/targeting/test/unit/head.js
new file mode 100644
index 0000000000..25ed4b243c
--- /dev/null
+++ b/toolkit/components/messaging-system/targeting/test/unit/head.js
@@ -0,0 +1,6 @@
+"use strict";
+// Globals
+
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
diff --git a/toolkit/components/messaging-system/targeting/test/unit/test_targeting.js b/toolkit/components/messaging-system/targeting/test/unit/test_targeting.js
new file mode 100644
index 0000000000..7d13e33751
--- /dev/null
+++ b/toolkit/components/messaging-system/targeting/test/unit/test_targeting.js
@@ -0,0 +1,327 @@
+const { ClientEnvironment } = ChromeUtils.importESModule(
+ "resource://normandy/lib/ClientEnvironment.sys.mjs"
+);
+const { TargetingContext } = ChromeUtils.importESModule(
+ "resource://messaging-system/targeting/Targeting.sys.mjs"
+);
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+add_task(async function instance_with_default() {
+ let targeting = new TargetingContext();
+
+ let res = await targeting.eval(
+ `ctx.locale == '${Services.locale.appLocaleAsBCP47}'`
+ );
+
+ Assert.ok(res, "Has local context");
+});
+
+add_task(async function instance_with_context() {
+ let targeting = new TargetingContext({ bar: 42 });
+
+ let res = await targeting.eval("ctx.bar == 42");
+
+ Assert.ok(res, "Merge provided context with default");
+});
+
+add_task(async function eval_1_context() {
+ let targeting = new TargetingContext();
+
+ let res = await targeting.eval("custom1.bar == 42", { custom1: { bar: 42 } });
+
+ Assert.ok(res, "Eval uses provided context");
+});
+
+add_task(async function eval_2_context() {
+ let targeting = new TargetingContext();
+
+ let res = await targeting.eval("custom1.bar == 42 && custom2.foo == 42", {
+ custom1: { bar: 42 },
+ custom2: { foo: 42 },
+ });
+
+ Assert.ok(res, "Eval uses provided context");
+});
+
+add_task(async function eval_multiple_context() {
+ let targeting = new TargetingContext();
+
+ let res = await targeting.eval(
+ "custom1.bar == 42 && custom2.foo == 42 && custom3.baz == 42",
+ { custom1: { bar: 42 }, custom2: { foo: 42 } },
+ { custom3: { baz: 42 } }
+ );
+
+ Assert.ok(res, "Eval uses provided context");
+});
+
+add_task(async function eval_multiple_context_precedence() {
+ let targeting = new TargetingContext();
+
+ let res = await targeting.eval(
+ "custom1.bar == 42 && custom2.foo == 42",
+ { custom1: { bar: 24 }, custom2: { foo: 24 } },
+ { custom1: { bar: 42 }, custom2: { foo: 42 } }
+ );
+
+ Assert.ok(res, "Last provided context overrides previously defined ones.");
+});
+
+add_task(async function eval_evalWithDefault() {
+ let targeting = new TargetingContext({ foo: 42 });
+
+ let res = await targeting.evalWithDefault("foo == 42");
+
+ Assert.ok(res, "Eval uses provided context");
+});
+
+add_task(async function log_targeting_error_events() {
+ let ctx = {
+ get foo() {
+ throw new Error("unit test");
+ },
+ };
+ let targeting = new TargetingContext(ctx);
+ let stub = sinon.stub(targeting, "_sendUndesiredEvent");
+
+ await Assert.rejects(
+ targeting.evalWithDefault("foo == 42", ctx),
+ /unit test/,
+ "Getter should throw"
+ );
+
+ Assert.equal(stub.callCount, 1, "Error event was logged");
+ let {
+ args: [{ event, value }],
+ } = stub.firstCall;
+ Assert.equal(event, "attribute_error", "Correct error message");
+ Assert.equal(value, "foo", "Correct attribute name");
+});
+
+add_task(async function eval_evalWithDefault_precedence() {
+ let targeting = new TargetingContext({ region: "space" });
+ let res = await targeting.evalWithDefault("region != 'space'");
+
+ Assert.ok(res, "Custom context does not override TargetingEnvironment");
+});
+
+add_task(async function eval_evalWithDefault_combineContexts() {
+ let combinedCtxs = TargetingContext.combineContexts({ foo: 1 }, { foo: 2 });
+ let targeting = new TargetingContext(combinedCtxs);
+ let res = await targeting.evalWithDefault("foo == 1");
+
+ Assert.ok(res, "First match is returned for combineContexts");
+});
+
+add_task(async function log_targeting_error_events_in_namespace() {
+ let ctx = {
+ get foo() {
+ throw new Error("unit test");
+ },
+ };
+ let targeting = new TargetingContext(ctx);
+ let stub = sinon.stub(targeting, "_sendUndesiredEvent");
+ let catchStub = sinon.stub();
+
+ try {
+ await targeting.eval("ctx.foo == 42");
+ } catch (e) {
+ catchStub();
+ }
+
+ Assert.equal(stub.callCount, 1, "Error event was logged");
+ let {
+ args: [{ event, value }],
+ } = stub.firstCall;
+ Assert.equal(event, "attribute_error", "Correct error message");
+ Assert.equal(value, "ctx.foo", "Correct attribute name");
+ Assert.ok(catchStub.calledOnce, "eval throws errors");
+});
+
+add_task(async function log_timeout_errors() {
+ let ctx = {
+ timeout: 1,
+ get foo() {
+ return new Promise(() => {});
+ },
+ };
+
+ let targeting = new TargetingContext(ctx);
+ let stub = sinon.stub(targeting, "_sendUndesiredEvent");
+ let catchStub = sinon.stub();
+
+ try {
+ await targeting.eval("ctx.foo");
+ } catch (e) {
+ catchStub();
+ }
+
+ Assert.equal(catchStub.callCount, 1, "Timeout error throws");
+ Assert.equal(stub.callCount, 1, "Timeout event was logged");
+ let {
+ args: [{ event, value }],
+ } = stub.firstCall;
+ Assert.equal(event, "attribute_timeout", "Correct error message");
+ Assert.equal(value, "ctx.foo", "Correct attribute name");
+});
+
+add_task(async function test_telemetry_event_timeout() {
+ Services.telemetry.clearEvents();
+ let ctx = {
+ timeout: 1,
+ get foo() {
+ return new Promise(() => {});
+ },
+ };
+ let expectedEvents = [
+ ["messaging_experiments", "targeting", "attribute_timeout", "ctx.foo"],
+ ];
+ let targeting = new TargetingContext(ctx);
+
+ try {
+ await targeting.eval("ctx.foo");
+ } catch (e) {}
+
+ TelemetryTestUtils.assertEvents(expectedEvents);
+ Services.telemetry.clearEvents();
+});
+
+add_task(async function test_telemetry_event_error() {
+ Services.telemetry.clearEvents();
+ let ctx = {
+ get bar() {
+ throw new Error("unit test");
+ },
+ };
+ let expectedEvents = [
+ ["messaging_experiments", "targeting", "attribute_error", "ctx.bar"],
+ ];
+ let targeting = new TargetingContext(ctx);
+
+ try {
+ await targeting.eval("ctx.bar");
+ } catch (e) {}
+
+ TelemetryTestUtils.assertEvents(expectedEvents);
+ Services.telemetry.clearEvents();
+});
+
+// Make sure that when using the Normandy-style ClientEnvironment context,
+// `liveTelemetry` works. `liveTelemetry` is a particularly tricky object to
+// proxy, so it's useful to check specifically.
+add_task(async function test_live_telemetry() {
+ let ctx = { env: ClientEnvironment };
+ let targeting = new TargetingContext();
+ // This shouldn't throw.
+ await targeting.eval("env.liveTelemetry.main", ctx);
+});
+
+add_task(async function test_default_targeting() {
+ const targeting = new TargetingContext();
+ const expected_attributes = [
+ "locale",
+ "localeLanguageCode",
+ // "region", // Not available in test, requires network access to determine
+ "userId",
+ "version",
+ "channel",
+ "platform",
+ ];
+
+ for (let attribute of expected_attributes) {
+ let res = await targeting.eval(`ctx.${attribute}`);
+ Assert.ok(res, `[eval] result for ${attribute} should not be null`);
+ }
+
+ for (let attribute of expected_attributes) {
+ let res = await targeting.evalWithDefault(attribute);
+ Assert.ok(
+ res,
+ `[evalWithDefault] result for ${attribute} should not be null`
+ );
+ }
+});
+
+add_task(async function test_targeting_os() {
+ const targeting = new TargetingContext();
+ await TestUtils.waitForCondition(() =>
+ targeting.eval("ctx.os.isWindows || ctx.os.isMac || ctx.os.isLinux")
+ );
+ let res = await targeting.eval(
+ `(ctx.os.isWindows && ctx.os.windowsVersion && ctx.os.windowsBuildNumber) ||
+ (ctx.os.isMac && ctx.os.macVersion && ctx.os.darwinVersion) ||
+ (ctx.os.isLinux && os.darwinVersion == null)
+ `
+ );
+ Assert.ok(res, `Should detect platform version got: ${res}`);
+});
+
+add_task(async function test_targeting_source_constructor() {
+ Services.telemetry.clearEvents();
+ const targeting = new TargetingContext(
+ {
+ foo: true,
+ get bar() {
+ throw new Error("bar");
+ },
+ },
+ { source: "unit_testing" }
+ );
+
+ let res = await targeting.eval("ctx.foo");
+ Assert.ok(res, "Should eval to true");
+
+ let expectedEvents = [
+ [
+ "messaging_experiments",
+ "targeting",
+ "attribute_error",
+ "ctx.bar",
+ { source: "unit_testing" },
+ ],
+ ];
+ try {
+ await targeting.eval("ctx.bar");
+ } catch (e) {}
+
+ TelemetryTestUtils.assertEvents(expectedEvents);
+ Services.telemetry.clearEvents();
+});
+
+add_task(async function test_targeting_source_override() {
+ Services.telemetry.clearEvents();
+ const targeting = new TargetingContext(
+ {
+ foo: true,
+ get bar() {
+ throw new Error("bar");
+ },
+ },
+ { source: "unit_testing" }
+ );
+
+ let res = await targeting.eval("ctx.foo");
+ Assert.ok(res, "Should eval to true");
+
+ let expectedEvents = [
+ [
+ "messaging_experiments",
+ "targeting",
+ "attribute_error",
+ "bar",
+ { source: "override" },
+ ],
+ ];
+ try {
+ targeting.setTelemetrySource("override");
+ await targeting.evalWithDefault("bar");
+ } catch (e) {}
+
+ TelemetryTestUtils.assertEvents(expectedEvents);
+ Services.telemetry.clearEvents();
+});
diff --git a/toolkit/components/messaging-system/targeting/test/unit/xpcshell.toml b/toolkit/components/messaging-system/targeting/test/unit/xpcshell.toml
new file mode 100644
index 0000000000..023bab422b
--- /dev/null
+++ b/toolkit/components/messaging-system/targeting/test/unit/xpcshell.toml
@@ -0,0 +1,6 @@
+[DEFAULT]
+head = "head.js"
+tags = "messaging-system"
+firefox-appdir = "browser"
+
+["test_targeting.js"]