diff options
Diffstat (limited to 'toolkit/components/messaging-system/targeting')
4 files changed, 584 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..8917169719 --- /dev/null +++ b/toolkit/components/messaging-system/targeting/Targeting.sys.mjs @@ -0,0 +1,245 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + 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", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + ASRouterTargeting: "resource://activity-stream/lib/ASRouterTargeting.jsm", +}); + +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.ini b/toolkit/components/messaging-system/targeting/test/unit/xpcshell.ini new file mode 100644 index 0000000000..3653c7f549 --- /dev/null +++ b/toolkit/components/messaging-system/targeting/test/unit/xpcshell.ini @@ -0,0 +1,6 @@ +[DEFAULT] +head = head.js +tags = messaging-system +firefox-appdir = browser + +[test_targeting.js] |