Bug 1771073 - Correct FxMS features and keep them up to date with tests r=dmose,pdahiya

The Nimbus Features corresponding to FxMS messaging surfaces are actually
intended to map to FxMS message groups, which can accept *any* FxMS message.
The features have been updated with schemas that accept any FxMS message.

As part of this, all FxMS schemas have been updated with an `$id` so that they
can be bundled into feature schemas and have their internal `$ref`s work.
(Otherwise, a `$ref` would be relative to the top-level schema instead of the
sub-schema).

Schemas for individual message types are no longer exposed as resource:// URIs,
except in tests, as indivual schemas are no longer required at runtime.

Additionally, each FxMS schema has had its `template` field become required and
requires a constant value for that schema (e.g., Spotlight requires a template
value of "spotlight").

A test has been added to ensure that if any of the messaging surfaces schemas
change that the feature schemas are also updated. The feature schemas can be
regenerated via:

```
cd ./browser/components/newtab/content-src/asrouter/schemas
../../../../../../mach make-schemas.py
```

Differential Revision: https://phabricator.services.mozilla.com/D147332
This commit is contained in:
Barret Rennie 2022-06-29 23:01:37 +00:00
parent 8b624ca222
commit edfd11a38c
24 changed files with 2836 additions and 285 deletions

View File

@ -1510,7 +1510,7 @@ pref("browser.newtabpage.activity-stream.asrouter.providers.message-groups", "{\
// this page over http opens us up to a man-in-the-middle attack that we'd rather not face. If you are a downstream
// repackager of this code using an alternate snippet url, please keep your users safe
pref("browser.newtabpage.activity-stream.asrouter.providers.snippets", "{\"id\":\"snippets\",\"enabled\":false,\"type\":\"remote\",\"url\":\"https://snippets.cdn.mozilla.net/%STARTPAGE_VERSION%/%NAME%/%VERSION%/%APPBUILDID%/%BUILD_TARGET%/%LOCALE%/%CHANNEL%/%OS_VERSION%/%DISTRIBUTION%/%DISTRIBUTION_VERSION%/\",\"updateCycleInMs\":14400000}");
pref("browser.newtabpage.activity-stream.asrouter.providers.messaging-experiments", "{\"id\":\"messaging-experiments\",\"enabled\":true,\"type\":\"remote-experiments\",\"messageGroups\":[\"cfr\",\"aboutwelcome\",\"infobar\",\"spotlight\",\"moments-page\",\"pbNewtab\"],\"updateCycleInMs\":3600000}");
pref("browser.newtabpage.activity-stream.asrouter.providers.messaging-experiments", "{\"id\":\"messaging-experiments\",\"enabled\":true,\"type\":\"remote-experiments\",\"updateCycleInMs\":3600000}");
// ASRouter user prefs
pref("browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons", true);

View File

@ -0,0 +1,161 @@
#!/usr/bin/env python3
# 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 json
import sys
from argparse import ArgumentParser
from itertools import chain
from pathlib import Path
SCHEMA_BASE_DIR = Path("..", "templates")
MESSAGE_TYPES = {
"CFRUrlbarChiclet": Path("CFR", "templates", "CFRUrlbarChiclet.schema.json"),
"ExtensionDoorhanger": Path("CFR", "templates", "ExtensionDoorhanger.schema.json"),
"InfoBar": Path("CFR", "templates", "InfoBar.schema.json"),
"NewtabPromoMessage": Path("PBNewtab", "NewtabPromoMessage.schema.json"),
"Spotlight": Path("OnboardingMessage", "Spotlight.schema.json"),
"ToolbarBadgeMessage": Path("OnboardingMessage", "ToolbarBadgeMessage.schema.json"),
"UpdateAction": Path("OnboardingMessage", "UpdateAction.schema.json"),
"WhatsNewMessage": Path("OnboardingMessage", "WhatsNewMessage.schema.json"),
}
def read_schema(path):
"""Read a schema from disk and parse it as JSON."""
with path.open("r") as f:
return json.load(f)
def extract_template_values(template):
"""Extract the possible template values (either via JSON Schema enum or const)."""
enum = template.get("enum")
if enum:
return enum
const = template.get("const")
if const:
return [const]
def main(check=False):
"""Generate Nimbus feature schemas for Firefox Messaging System."""
defs = {
name: read_schema(SCHEMA_BASE_DIR / path)
for name, path in MESSAGE_TYPES.items()
}
# Ensure all bundled schemas have an $id so that $refs inside the
# bundled schema work correctly (i.e, they will reference the subschema
# and not the bundle).
for name, schema in defs.items():
if "$id" not in schema:
raise ValueError(f"Schema {name} is missing an $id")
props = schema["properties"]
if "template" not in props:
raise ValueError(f"Schema {name} is missing a template")
template = props["template"]
if "enum" not in template and "const" not in template:
raise ValueError(f"Schema {name} should have const or enum template")
filename = Path("MessagingExperiment.schema.json")
templates = {
name: extract_template_values(schema["properties"]["template"])
for name, schema in defs.items()
}
# Ensure that each schema has a unique set of template values.
for a in templates.keys():
a_keys = set(templates[a])
for b in templates.keys():
if a == b:
continue
b_keys = set(templates[b])
intersection = a_keys.intersection(b_keys)
if len(intersection):
raise ValueError(
f"Schema {a} and {b} have overlapping template values: {', '.join(intersection)}"
)
all_templates = list(chain.from_iterable(templates.values()))
feature_schema = {
"$schema": "https://json-schema.org/draft/2019-09/schema",
"$id": "resource://activity-stream/schemas/MessagingExperiment.schema.json",
"title": "Messaging Experiment",
"description": "A Firefox Messaging System message.",
"allOf": [
# Enforce that one of the templates must match (so that one of the
# if branches will match).
{
"type": "object",
"properties": {
"template": {
"type": "string",
"enum": all_templates,
},
},
"required": ["template"],
},
*(
{
"if": {
"type": "object",
"properties": {
"template": {
"type": "string",
"enum": templates[message_type],
},
},
"required": ["template"],
},
"then": {"$ref": f"#/$defs/{message_type}"},
}
for message_type in defs
),
],
"$defs": defs,
}
if check:
print(f"Checking {filename} ...")
with filename.open("r") as f:
on_disk = json.load(f)
if on_disk != feature_schema:
print(f"{filename} does not match generated schema")
print("Generated schema:")
json.dump(feature_schema, sys.stdout)
print("\n\nCommitted schema:")
json.dump(on_disk, sys.stdout)
raise ValueError("Schemas do not match!")
else:
with filename.open("wb") as f:
print(f"Generating {filename} ...")
f.write(json.dumps(feature_schema, indent=2).encode("utf-8"))
f.write(b"\n")
if __name__ == "__main__":
parser = ArgumentParser(description=main.__doc__)
parser.add_argument(
"--check",
action="store_true",
help="Check that the generated schemas have not changed",
default=False,
)
args = parser.parse_args()
main(args.check)

View File

@ -1,4 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2019-09/schema",
"$id": "file:///CFRUrlbarChiclet.schema.json",
"title": "CFRUrlbarChiclet",
"description": "A template with a chiclet button with text.",
"version": "1.0.0",
@ -102,7 +104,8 @@
"description": "A JEXL expression representing targeting information"
},
"template": {
"type": "string"
"type": "string",
"const": "cfr_urlbar_chiclet"
},
"trigger": {
"type": "object",
@ -123,5 +126,5 @@
"required": ["id"]
}
},
"required": ["id", "groups", "content", "targeting", "template", "trigger"]
"required": ["id", "content", "targeting", "template", "trigger"]
}

View File

@ -1,4 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2019-09/schema",
"$id": "file:///ExtensionDoorhanger.schema.json",
"title": "ExtensionDoorhanger",
"description": "A template with a heading, addon icon, title and description. No markup allowed.",
"version": "1.0.0",
@ -87,8 +89,22 @@
"type": "object",
"properties": {
"tooltiptext": {
"type": "string",
"description": "Text for button tooltip used to provide information about the doorhanger."
"oneOf": [
{
"type": "string",
"description": "Text for button tooltip used to provide information about the doorhanger."
},
{
"type": "object",
"properties": {
"string_id": {
"type": "string",
"description": "Id of localized string for the tooltip."
}
},
"required": ["string_id"]
}
]
}
},
"required": ["tooltiptext"]
@ -390,13 +406,24 @@
"additionalProperties": true,
"required": [
"layout",
"category",
"bucket_id",
"notification_text",
"heading_text",
"text",
"buttons"
]
],
"if": {
"properties": {
"skip_address_bar_notifier": {
"anyOf": [
{ "const": "false" },
{ "const": null }
]
}
}
},
"then": {
"required": ["category", "notification_text"]
}
},
"priority": {
"type": "integer"
@ -406,7 +433,8 @@
"description": "A JEXL expression representing targeting information"
},
"template": {
"type": "string"
"type": "string",
"enum": ["cfr_doorhanger", "milestone_message"]
},
"trigger": {
"type": "object",
@ -428,5 +456,5 @@
}
},
"additionalProperties": true,
"required": ["id", "groups", "content", "targeting", "template", "trigger"]
"required": ["id", "content", "targeting", "template", "trigger"]
}

View File

@ -1,4 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2019-09/schema",
"$id": "file:///InfoBar.schema.json",
"title": "InfoBar",
"description": "A template with an image, test and buttons.",
"version": "1.0.0",
@ -169,7 +171,8 @@
"description": "A JEXL expression representing targeting information"
},
"template": {
"type": "string"
"type": "string",
"const": "infobar"
},
"trigger": {
"type": "object",
@ -191,5 +194,5 @@
}
},
"additionalProperties": true,
"required": ["id", "groups", "content", "targeting", "template", "trigger"]
"required": ["id", "content", "targeting", "template", "trigger"]
}

View File

@ -1,4 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2019-09/schema",
"$id": "file:///Spotlight.schema.json",
"title": "Spotlight",
"description": "A template with an image, title, content and two buttons.",
"version": "1.1.0",
@ -282,7 +284,8 @@
"description": "A JEXL expression representing targeting information"
},
"template": {
"type": "string"
"type": "string",
"const": "spotlight"
},
"trigger": {
"type": "object",
@ -304,5 +307,5 @@
}
},
"additionalProperties": true,
"required": ["id", "groups", "content", "targeting", "template", "trigger"]
"required": ["id", "content", "targeting", "template"]
}

View File

@ -1,39 +1,63 @@
{
"$schema": "https://json-schema.org/draft/2019-09/schema",
"$id": "file:///ToolbarBadgeMessage.schema.json",
"title": "ToolbarBadgeMessage",
"description": "A template that specifies to which element in the browser toolbar to add a notification.",
"version": "1.1.0",
"type": "object",
"properties": {
"target": {
"content": {
"type": "object",
"properties": {
"target": {
"type": "string"
},
"action": {
"type": "object",
"properties": {
"id": {
"type": "string"
}
},
"additionalProperties": true,
"required": [
"id"
],
"description": "Optional action to take in addition to showing the notification"
},
"delay": {
"type": "number",
"description": "Optional delay in ms after which to show the notification"
},
"badgeDescription": {
"type": "object",
"description": "This is used in combination with the badged button to offer a text based alternative to the visual badging. Example 'New Feature: What's New'",
"properties": {
"string_id": {
"type": "string",
"description": "Fluent string id"
}
},
"required": [
"string_id"
]
}
},
"additionalProperties": true,
"required": ["target"]
},
"targeting": {
"type": "string"
},
"action": {
"type": "object",
"properties": {
"id": {
"type": "string"
}
},
"additionalProperties": false,
"required": ["id"],
"description": "Optional action to take in addition to showing the notification"
},
"delay": {
"type": "number",
"description": "Optional delay in ms after which to show the notification"
},
"badgeDescription": {
"type": "object",
"description": "This is used in combination with the badged button to offer a text based alternative to the visual badging. Example 'New Feature: What's New'",
"properties": {
"string_id": {
"type": "string",
"description": "Fluent string id"
}
},
"required": ["string_id"]
"template": {
"type": "string",
"const": "toolbar_badge"
}
},
"additionalProperties": false,
"required": ["target"]
"additionalProperties": true,
"required": [
"content",
"targeting",
"template"
]
}

View File

@ -1,5 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2019-09/schema",
"$id": "file:///UpdateAction.schema.json",
"title": "UpdateActionMessage",
"description": "A template for messages that execute predetermined actions.",
"version": "1.0.0",
@ -54,7 +55,8 @@
"description": "A JEXL expression representing targeting information"
},
"template": {
"type": "string"
"type": "string",
"const": "update_action"
},
"trigger": {
"type": "object",
@ -74,5 +76,6 @@
},
"required": ["id"]
}
}
},
"required": ["id", "content", "targeting", "template"]
}

View File

@ -1,4 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2019-09/schema",
"$id": "file:///WhatsNewMessage.schema.json",
"title": "WhatsNewMessage",
"description": "A template for the messages that appear in the What's New panel.",
"version": "1.2.0",
@ -17,81 +19,141 @@
"type": "string"
}
},
"required": ["string_id"],
"required": [
"string_id"
],
"description": "Id of localized string to be rendered."
}
]
}
},
"properties": {
"layout": {
"description": "Different message layouts",
"enum": ["tracking-protections"]
"content": {
"type": "object",
"properties": {
"layout": {
"description": "Different message layouts",
"enum": [
"tracking-protections"
]
},
"layout_title_content_variable": {
"description": "Select what profile specific value to show for the current layout.",
"type": "string"
},
"bucket_id": {
"type": "string",
"description": "A bucket identifier for the addon. This is used in order to anonymize telemetry for history-sensitive targeting."
},
"published_date": {
"type": "integer",
"description": "The date/time (number of milliseconds elapsed since January 1, 1970 00:00:00 UTC) the message was published."
},
"title": {
"allOf": [
{
"$ref": "#/definitions/localizableText"
},
{
"description": "Id of localized string or message override of What's New message title"
}
]
},
"subtitle": {
"allOf": [
{
"$ref": "#/definitions/localizableText"
},
{
"description": "Id of localized string or message override of What's New message subtitle"
}
]
},
"body": {
"allOf": [
{
"$ref": "#/definitions/localizableText"
},
{
"description": "Id of localized string or message override of What's New message body"
}
]
},
"link_text": {
"allOf": [
{
"$ref": "#/definitions/localizableText"
},
{
"description": "(optional) Id of localized string or message override of What's New message link text"
}
]
},
"cta_url": {
"description": "Target URL for the What's New message.",
"type": "string",
"format": "moz-url-format"
},
"cta_type": {
"description": "Type of url open action",
"enum": [
"OPEN_URL",
"OPEN_ABOUT_PAGE",
"OPEN_PROTECTION_REPORT"
]
},
"cta_where": {
"description": "How to open the cta: new window, tab, focused, unfocused.",
"enum": [
"current",
"tabshifted",
"tab",
"save",
"window"
]
},
"icon_url": {
"description": "(optional) URL for the What's New message icon.",
"type": "string",
"format": "uri"
},
"icon_alt": {
"allOf": [
{
"$ref": "#/definitions/localizableText"
},
{
"description": "Alt text for image."
}
]
}
},
"additionalProperties": true,
"required": [
"published_date",
"title",
"body",
"cta_url",
"bucket_id"
],
"dependencies": {
"layout": [
"layout_title_content_variable"
]
}
},
"layout_title_content_variable": {
"description": "Select what profile specific value to show for the current layout.",
"type": "string"
"order": {
"type": "integer"
},
"bucket_id": {
"template": {
"type": "string",
"description": "A bucket identifier for the addon. This is used in order to anonymize telemetry for history-sensitive targeting."
},
"published_date": {
"type": "integer",
"description": "The date/time (number of milliseconds elapsed since January 1, 1970 00:00:00 UTC) the message was published."
},
"title": {
"allOf": [
{"$ref": "#/definitions/localizableText"},
{"description": "Id of localized string or message override of What's New message title"}
]
},
"subtitle": {
"allOf": [
{"$ref": "#/definitions/localizableText"},
{"description": "Id of localized string or message override of What's New message subtitle"}
]
},
"body": {
"allOf": [
{"$ref": "#/definitions/localizableText"},
{"description": "Id of localized string or message override of What's New message body"}
]
},
"link_text": {
"allOf": [
{"$ref": "#/definitions/localizableText"},
{"description": "(optional) Id of localized string or message override of What's New message link text"}
]
},
"cta_url": {
"description": "Target URL for the What's New message.",
"type": "string",
"format": "moz-url-format"
},
"cta_type": {
"description": "Type of url open action",
"enum": ["OPEN_URL", "OPEN_ABOUT_PAGE", "OPEN_PROTECTION_REPORT"]
},
"cta_where": {
"description": "How to open the cta: new window, tab, focused, unfocused.",
"enum": ["current", "tabshifted", "tab", "save", "window"]
},
"icon_url": {
"description": "(optional) URL for the What's New message icon.",
"type": "string",
"format": "uri"
},
"icon_alt": {
"allOf": [
{"$ref": "#/definitions/localizableText"},
{"description": "Alt text for image."}
]
"const": "whatsnew_panel_message"
}
},
"additionalProperties": true,
"required": ["published_date", "title", "body", "cta_url", "bucket_id"],
"dependencies": {
"layout": ["layout_title_content_variable"]
}
"required": [
"content",
"order",
"template"
],
"additionalProperties": true
}

View File

@ -1,88 +1,188 @@
{
"$schema": "https://json-schema.org/draft/2019-09/schema",
"$id": "file:///NewtabPromoMessage.schema.json",
"title": "PBNewtabPromoMessage",
"description": "Message shown on the private browsing newtab page.",
"version": "1.0.0",
"type": "object",
"properties": {
"infoEnabled": {
"type": "boolean",
"description": "Should we show the info section."
},
"infoIcon": {
"id": {
"type": "string",
"description": "Icon shown in the left side of the info section. Default is the private browsing icon."
"description": "Message identifier"
},
"infoTitle": {
"groups": {
"description": "Array of preferences used to control `enabled` status of the group. If any is `false` the group is disabled.",
"type": "array",
"items": {
"type": "string",
"description": "Preference name"
}
},
"content": {
"type": "object",
"properties": {
"hideDefault": {
"type": "boolean",
"description": "Should we hide the default promo after the experiment promo is dismissed."
},
"infoEnabled": {
"type": "boolean",
"description": "Should we show the info section."
},
"infoIcon": {
"type": "string",
"description": "Icon shown in the left side of the info section. Default is the private browsing icon."
},
"infoTitle": {
"type": "string",
"description": "Is the title in the info section enabled."
},
"infoTitleEnabled": {
"type": "boolean",
"description": "Is the title in the info section enabled."
},
"infoBody": {
"type": "string",
"description": "Text content in the info section."
},
"infoLinkText": {
"type": "string",
"description": "Text for the link in the info section."
},
"infoLinkUrl": {
"type": "string",
"description": "URL for the info section link.",
"format": "moz-url-format"
},
"promoEnabled": {
"type": "boolean",
"description": "Should we show the promo section."
},
"promoType": {
"type": "string",
"description": "Promo type used to determine if promo should show to a given user",
"enum": [
"FOCUS",
"RALLY",
"VPN"
]
},
"promoSectionStyle": {
"type": "string",
"description": "Sets the position of the promo section. Possible values are: top, below-search, bottom. Default bottom.",
"enum": [
"top",
"below-search",
"bottom"
]
},
"promoTitle": {
"type": "string",
"description": "The text content of the promo section."
},
"promoTitleEnabled": {
"type": "boolean",
"description": "Should we show text content in the promo section."
},
"promoLinkText": {
"type": "string",
"description": "The text of the link in the promo box."
},
"promoHeader": {
"type": "string",
"description": "The title of the promo section."
},
"promoButton": {
"type": "object",
"properties": {
"action": {
"type": "object",
"properties": {
"type": {
"type": "string",
"description": "Action dispatched by the button."
},
"data": {
"type": "object"
}
},
"required": ["type"],
"additionalProperties": true
}
},
"required": ["action"]
},
"promoLinkType": {
"type": "string",
"description": "Type of promo link type. Possible values: link, button. Default is link.",
"enum": [
"link",
"button"
]
},
"promoImageLarge": {
"type": "string",
"description": "URL for image used on the left side of the promo box, larger, showcases some feature. Default off.",
"format": "uri"
},
"promoImageSmall": {
"type": "string",
"description": "URL for image used on the right side of the promo box, smaller, usually a logo. Default off.",
"format": "uri"
}
},
"additionalProperties": true,
"allOf": [
{
"if": {
"properties": {
"promoEnabled": { "const": true }
},
"required": ["promoEnabled"]
},
"then": {
"required": ["promoButton"]
}
},
{
"if": {
"properties": {
"infoEnabled": { "const": true }
},
"required": ["infoEnabled"]
},
"then": {
"required": ["infoLinkText"],
"if": {
"properties": {
"infoTitleEnabled": { "const": true }
},
"required": ["infoTitleEnabled"]
},
"then": {
"required": ["infoTitle"]
}
}
}
]
},
"priority": {
"type": "integer"
},
"targeting": {
"type": "string",
"description": "Is the title in the info section enabled."
"description": "A JEXL expression represetning targeting information"
},
"infoTitleEnabled": {
"type": "boolean",
"description": "Is the title in the info section enabled."
},
"infoBody": {
"template": {
"type": "string",
"description": "Text content in the info section."
},
"infoLinkText": {
"type": "string",
"description": "Text for the link in the info section."
},
"infoLinkUrl": {
"type": "string",
"description": "URL for the info section link.",
"format": "moz-url-format"
},
"promoEnabled": {
"type": "boolean",
"description": "Should we show the promo section."
},
"promoType": {
"type": "string",
"description": "Promo type used to determine if promo should show to a given user",
"enum": ["FOCUS", "RALLY", "VPN"]
},
"promoSectionStyle": {
"type": "string",
"description": "Sets the position of the promo section. Possible values are: top, below-search, bottom. Default bottom.",
"enum": ["top", "below-search", "bottom"]
},
"promoTitle": {
"type": "string",
"description": "The text content of the promo section."
},
"promoTitleEnabled": {
"type": "boolean",
"description": "Should we show text content in the promo section."
},
"promoLinkText": {
"type": "string",
"description": "The text of the link in the promo box."
},
"promoHeader": {
"type": "string",
"description": "The title of the promo section."
},
"promoLinkUrl": {
"type": "string",
"description": "URL for link in the promo box.",
"format": "moz-url-format"
},
"promoLinkType": {
"type": "string",
"description": "Type of promo link type. Possible values: link, button. Default is link.",
"enum": ["link", "button"]
},
"promoImageLarge": {
"type": "string",
"description": "URL for image used on the left side of the promo box, larger, showcases some feature. Default off.",
"format": "uri"
},
"promoImageSmall": {
"type": "string",
"description": "URL for image used on the right side of the promo box, smaller, usually a logo. Default off.",
"format": "uri"
"const": "pb_newtab"
}
},
"required": [
"id",
"content",
"targeting",
"template"
],
"additionalProperties": true
}

View File

@ -11,12 +11,7 @@ browser.jar:
res/activity-stream/aboutwelcome/aboutwelcome.bundle.js (./aboutwelcome/content/aboutwelcome.bundle.js)
res/activity-stream/aboutwelcome/aboutwelcome.html (./aboutwelcome/content/aboutwelcome.html)
res/activity-stream/aboutwelcome/lib/ (./aboutwelcome/lib/*)
res/activity-stream/schemas/CFR/ExtensionDoorhanger.schema.json (./content-src/asrouter/templates/CFR/templates/ExtensionDoorhanger.schema.json)
res/activity-stream/schemas/CFR/InfoBar.schema.json (./content-src/asrouter/templates/CFR/templates/InfoBar.schema.json)
res/activity-stream/schemas/OnboardingMessage/UpdateAction.schema.json (./content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json)
res/activity-stream/schemas/OnboardingMessage/Spotlight.schema.json (./content-src/asrouter/templates/OnboardingMessage/Spotlight.schema.json)
res/activity-stream/schemas/OnboardingMessage/WhatsNewMessage.schema.json (./content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json)
res/activity-stream/schemas/PBNewtab/NewtabPromoMessage.schema.json (./content-src/asrouter/templates/PBNewtab/NewtabPromoMessage.schema.json)
res/activity-stream/schemas/MessagingExperiment.schema.json (./content-src/asrouter/schemas/MessagingExperiment.schema.json)
res/activity-stream/vendor/Redux.jsm (./vendor/Redux.jsm)
res/activity-stream/vendor/react.js (./vendor/react.js)
res/activity-stream/vendor/react-dom.js (./vendor/react-dom.js)

View File

@ -328,9 +328,17 @@ const MessageLoaderUtils = {
},
async _experimentsAPILoader(provider) {
// Allow tests to override the set of featureIds
const featureIds = provider.featureIds ?? [
"cfr",
"infobar",
"moments-page",
"pbNewtab",
"spotlight",
];
let experiments = [];
for (const featureId of provider.messageGroups) {
let FeatureAPI = lazy.NimbusFeatures[featureId];
for (const featureId of featureIds) {
let featureAPI = lazy.NimbusFeatures[featureId];
let experimentData = lazy.ExperimentAPI.getExperimentMetaData({
featureId,
});
@ -339,9 +347,13 @@ const MessageLoaderUtils = {
continue;
}
let message = FeatureAPI.getAllVariables();
let message = featureAPI.getAllVariables();
if (message?.id) {
// Cache the Nimbus feature ID on the message because there is not a 1-1
// correspondance between templates and features. This is used when
// recording expose events (see |sendTriggerMessage|).
message._nimbusFeature = featureId;
experiments.push(message);
}
@ -1694,7 +1706,7 @@ class _ASRouter {
TelemetryStopwatch.finish("MS_MESSAGE_REQUEST_TIME_MS", telemetryObject);
// Format urls if any are defined
["infoLinkUrl", "promoLinkUrl"].forEach(key => {
["infoLinkUrl"].forEach(key => {
if (message?.content?.[key]) {
message.content[key] = Services.urlFormatter.formatURL(
message.content[key]
@ -1779,17 +1791,9 @@ class _ASRouter {
}
if (nonReachMessages.length) {
// Map from message template to Nimbus feature
let featureMap = {
cfr_doorhanger: "cfr",
spotlight: "spotlight",
infobar: "infobar",
update_action: "moments-page",
pb_newtab: "pbNewtab",
};
let feature = featureMap[nonReachMessages[0].template];
if (feature) {
lazy.NimbusFeatures[feature].recordExposureEvent({ once: true });
let featureId = nonReachMessages[0]._nimbusFeature;
if (featureId) {
lazy.NimbusFeatures[featureId].recordExposureEvent({ once: true });
}
}

View File

@ -160,6 +160,7 @@ const MESSAGES = () => [
content: {
layout: "icon_and_message",
category: "cfrFeatures",
bucket_id: "PERSONALIZED_CFR_MESSAGE",
notification_text: "Personalized CFR Recommendation",
heading_text: { string_id: "cfr-doorhanger-bookmark-fxa-header" },
info_icon: {
@ -635,8 +636,6 @@ const MESSAGES = () => [
promoEnabled: true,
promoType: "VPN",
infoEnabled: true,
infoIcon: "",
infoTitle: "",
infoBody: "fluent:about-private-browsing-info-description-private-window",
infoLinkText: "fluent:about-private-browsing-learn-more-link",
infoTitleEnabled: false,
@ -647,7 +646,16 @@ const MESSAGES = () => [
promoTitle: "fluent:about-private-browsing-hide-activity-1",
promoTitleEnabled: true,
promoImageLarge: "chrome://browser/content/assets/moz-vpn.svg",
promoButton: {
action: {
type: "OPEN_URL",
data: {
args: "https://vpn.mozilla.org/",
},
},
},
},
groups: ["panel-test-provider"],
targeting: "region != 'CN' && !hasActiveEnterprisePolicies",
frequency: { lifetime: 3 },
},

View File

@ -13,6 +13,12 @@ BROWSER_CHROME_MANIFESTS += [
]
TESTING_JS_MODULES += [
"content-src/asrouter/templates/CFR/templates/ExtensionDoorhanger.schema.json",
"content-src/asrouter/templates/OnboardingMessage/Spotlight.schema.json",
"content-src/asrouter/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json",
"content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json",
"content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json",
"content-src/asrouter/templates/PBNewtab/NewtabPromoMessage.schema.json",
"test/RemoteImagesTestUtils.jsm",
]

View File

@ -180,7 +180,7 @@ async function setup(experiment) {
["datareporting.healthreport.uploadEnabled", true],
[
"browser.newtabpage.activity-stream.asrouter.providers.messaging-experiments",
`{"id":"messaging-experiments","enabled":true,"type":"remote-experiments","messageGroups":["cfr","spotlight","infobar","aboutwelcome"],"updateCycleInMs":0}`,
`{"id":"messaging-experiments","enabled":true,"type":"remote-experiments","updateCycleInMs":0}`,
],
],
});

View File

@ -223,9 +223,10 @@ describe("ASRouter", () => {
};
let fakeNimbusFeatures = [
"cfr",
"moments-page",
"infobar",
"spotlight",
"moments-page",
"pbNewtab",
].reduce((features, featureId) => {
features[featureId] = {
getAllVariables: sandbox.stub().returns(null),
@ -1736,11 +1737,11 @@ describe("ASRouter", () => {
describe("#reachEvent", () => {
let experimentAPIStub;
let messageGroups = ["cfr", "moments-page", "infobar", "spotlight"];
let featureIds = ["cfr", "moments-page", "infobar", "spotlight"];
beforeEach(() => {
let getExperimentMetaDataStub = sandbox.stub();
let getAllBranchesStub = sandbox.stub();
messageGroups.forEach(feature => {
featureIds.forEach(feature => {
global.NimbusFeatures[feature].getAllVariables.returns({
id: `message-${feature}`,
});
@ -1770,15 +1771,15 @@ describe("ASRouter", () => {
// This should match the `providers.messaging-experiments`
let response = await MessageLoaderUtils.loadMessagesForProvider({
type: "remote-experiments",
messageGroups,
featureIds,
});
// 1 message for reach 1 for expose
assert.property(response, "messages");
assert.lengthOf(response.messages, messageGroups.length * 2);
assert.lengthOf(response.messages, featureIds.length * 2);
assert.lengthOf(
response.messages.filter(m => m.forReachEvent),
messageGroups.length
featureIds.length
);
});
});
@ -2621,7 +2622,7 @@ describe("ASRouter", () => {
it("should fetch messages from the ExperimentAPI", async () => {
const args = {
type: "remote-experiments",
messageGroups: ["spotlight"],
featureIds: ["spotlight"],
};
await MessageLoaderUtils.loadMessagesForProvider(args);
@ -2635,7 +2636,7 @@ describe("ASRouter", () => {
it("should handle the case of no experiments in the ExperimentAPI", async () => {
const args = {
type: "remote-experiments",
messageGroups: ["infobar"],
featureIds: ["infobar"],
};
global.ExperimentAPI.getExperiment.returns(null);
@ -2647,7 +2648,7 @@ describe("ASRouter", () => {
it("should normally load ExperimentAPI messages", async () => {
const args = {
type: "remote-experiments",
messageGroups: ["infobar"],
featureIds: ["infobar"],
};
const enrollment = {
branch: {
@ -2683,7 +2684,7 @@ describe("ASRouter", () => {
it("should skip disabled features and not load the messages", async () => {
const args = {
type: "remote-experiments",
messageGroups: ["cfr"],
featureIds: ["cfr"],
};
global.NimbusFeatures.cfr.getAllVariables.returns(null);
@ -2695,7 +2696,7 @@ describe("ASRouter", () => {
it("should fetch branches with trigger", async () => {
const args = {
type: "remote-experiments",
messageGroups: ["cfr"],
featureIds: ["cfr"],
};
const enrollment = {
slug: "exp01",
@ -2750,7 +2751,7 @@ describe("ASRouter", () => {
it("should fetch branches with trigger even if enrolled branch is disabled", async () => {
const args = {
type: "remote-experiments",
messageGroups: ["cfr"],
featureIds: ["cfr"],
};
const enrollment = {
slug: "exp01",
@ -2763,7 +2764,7 @@ describe("ASRouter", () => {
},
};
// Nedds to match the `messageGroups` value to return an enrollment
// Nedds to match the `featureIds` value to return an enrollment
// for that feature
global.NimbusFeatures.cfr.getAllVariables.returns(
enrollment.branch.cfr.value

View File

@ -1,11 +1,13 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
XPCOMUtils.defineLazyModuleGetters(this, {
OnboardingMessageProvider:
"resource://activity-stream/lib/OnboardingMessageProvider.jsm",
sinon: "resource://testing-common/Sinon.jsm",
});
const { JsonSchema } = ChromeUtils.import(
"resource://gre/modules/JsonSchema.jsm"
);
const { OnboardingMessageProvider } = ChromeUtils.import(
"resource://activity-stream/lib/OnboardingMessageProvider.jsm"
);
const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm");
add_task(
async function test_OnboardingMessageProvider_getUpgradeMessage_no_pin() {
@ -71,3 +73,64 @@ add_task(
sandbox.restore();
}
);
add_task(async function test_schemaValidation() {
function schemaValidator(uri) {
return fetch(uri, { credentials: "omit" })
.then(rsp => rsp.json())
.then(schema => new JsonSchema.Validator(schema));
}
function assertValid(validator, obj, msg) {
const result = validator.validate(obj);
Assert.deepEqual(
result,
{ valid: true, errors: [] },
`${msg} - errors = ${JSON.stringify(result.errors, undefined, 2)}`
);
}
const experimentValidator = await schemaValidator(
"resource://activity-stream/schemas/MessagingExperiment.schema.json"
);
const schemas = {
toolbar_badge: await schemaValidator(
"resource://testing-common/ToolbarBadgeMessage.schema.json"
),
cfr_doorhanger: await schemaValidator(
"resource://testing-common/ExtensionDoorhanger.schema.json"
),
spotlight: await schemaValidator(
"resource://testing-common/Spotlight.schema.json"
),
pb_newtab: await schemaValidator(
"resource://testing-common/NewtabPromoMessage.schema.json"
),
protections_panel: null, // TODO: There is no schema for protections_panel.
};
const messages = await OnboardingMessageProvider.getMessages();
for (const message of messages) {
const validator = schemas[message.template];
if (validator === null) {
continue;
} else if (typeof validator === "undefined") {
Assert.ok(
false,
`No schema validator found for message template ${message.template}. Please update this test to add one.`
);
} else {
assertValid(
validator,
message,
`Message ${message.id} validates as template ${message.template}`
);
assertValid(
experimentValidator,
message,
`Message ${message.id} validates as MessagingExperiment`
);
}
}
});

View File

@ -10,6 +10,7 @@ const { JsonSchema } = ChromeUtils.import(
Cu.importGlobalProperties(["fetch"]);
let MESSAGING_EXPERIMENT_SCHEMA;
let CFR_SCHEMA;
let UPDATE_ACTION_SCHEMA;
let WHATS_NEW_SCHEMA;
@ -21,20 +22,23 @@ add_setup(async function setup() {
return fetch(uri, { credentials: "omit" }).then(rsp => rsp.json());
}
MESSAGING_EXPERIMENT_SCHEMA = await fetchSchema(
"resource://activity-stream/schemas/MessagingExperiment.schema.json"
);
CFR_SCHEMA = await fetchSchema(
"resource://activity-stream/schemas/CFR/ExtensionDoorhanger.schema.json"
"resource://testing-common/ExtensionDoorhanger.schema.json"
);
UPDATE_ACTION_SCHEMA = await fetchSchema(
"resource://activity-stream/schemas/OnboardingMessage/UpdateAction.schema.json"
"resource://testing-common/UpdateAction.schema.json"
);
WHATS_NEW_SCHEMA = await fetchSchema(
"resource://activity-stream/schemas/OnboardingMessage/WhatsNewMessage.schema.json"
"resource://testing-common/WhatsNewMessage.schema.json"
);
SPOTLIGHT_SCHEMA = await fetchSchema(
"resource://activity-stream/schemas/OnboardingMessage/Spotlight.schema.json"
"resource://testing-common/Spotlight.schema.json"
);
PB_NEWTAB_SCHEMA = await fetchSchema(
"resource://activity-stream/schemas/PBNewtab/NewtabPromoMessage.schema.json"
"resource://testing-common/NewtabPromoMessage.schema.json"
);
});
@ -42,7 +46,13 @@ function assertSchema(obj, schema, log) {
Assert.deepEqual(
JsonSchema.validate(obj, schema),
{ valid: true, errors: [] },
log
`${log} (${schema.title} schema)`
);
Assert.deepEqual(
JsonSchema.validate(obj, MESSAGING_EXPERIMENT_SCHEMA),
{ valid: true, errors: [] },
`${log} (MessagingExperiment schema)`
);
}
@ -57,6 +67,12 @@ add_task(async function test_PanelTestProvider() {
"PanelTestProvider should have the correct number of messages"
);
for (const [i, msg] of messages
.filter(m => ["cfr_doorhanger", "milestone_message"].includes(m.template))
.entries()) {
assertSchema(msg, CFR_SCHEMA, `cfr message ${msg.id ?? i} is valid`);
}
for (const [i, msg] of messages
.filter(m => m.template === "update_action")
.entries()) {
@ -71,14 +87,10 @@ add_task(async function test_PanelTestProvider() {
.filter(m => m.template === "whatsnew_panel_message")
.entries()) {
assertSchema(
msg.content,
msg,
WHATS_NEW_SCHEMA,
`whatsnew_panel_message message ${msg.id ?? i} is valid`
);
Assert.ok(
Object.keys(msg).includes("order"),
`whatsnew_panel_message message ${msg.id ?? i} has "order" property`
);
}
for (const [i, msg] of messages
@ -107,35 +119,3 @@ add_task(async function test_PanelTestProvider() {
"There is one pb_newtab message"
);
});
add_task(async function test_SpotlightAsCFR() {
let message = await PanelTestProvider.getMessages().then(msgs =>
msgs.find(msg => msg.id === "SPOTLIGHT_MESSAGE_93")
);
message = {
...message,
content: {
...message.content,
category: "",
layout: "icon_and_message",
bucket_id: "",
notification_text: "",
heading_text: "",
text: "",
buttons: {},
},
};
assertSchema(
message,
CFR_SCHEMA,
"Munged spotlight message validates with CFR ExtensionDoorhanger schema"
);
assertSchema(
message,
SPOTLIGHT_SCHEMA,
"Munged Spotlight message validates with Spotlight schema"
);
});

View File

@ -40,7 +40,7 @@ async function renderInfo({
infoIcon,
} = {}) {
const container = document.querySelector(".info");
if (infoEnabled === false) {
if (!infoEnabled) {
container.remove();
return;
}

View File

@ -153,7 +153,7 @@ async function setupMSExperimentWithMessage(message) {
set: [
[
"browser.newtabpage.activity-stream.asrouter.providers.messaging-experiments",
'{"id":"messaging-experiments","enabled":true,"type":"remote-experiments","messageGroups":["pbNewtab"],"updateCycleInMs":0}',
'{"id":"messaging-experiments","enabled":true,"type":"remote-experiments","updateCycleInMs":0}',
],
],
});

View File

@ -422,6 +422,21 @@ mozperftest:
- 'testing/performance/**'
- 'python/mozperftest/**'
fxms-schemas:
description: Ensure messaging-system schemas are up-to-date
treeherder:
symbol: py3(fxms)
run:
using: run-task
cwd: '{checkout}'
command: >
cd browser/components/newtab/content-src/asrouter/schemas &&
python3 make-schemas.py --check
when:
files-changed:
- 'browser/components/newtab/content-src/asrouter/schemas/make-schemas.py'
- 'browser/components/newtab/content-src/**/*.schema.json'
condprof:
description: testing/condprofile unit tests
platform:

View File

@ -8,6 +8,41 @@ More information about `Messaging System`__.
.. __: /browser/components/newtab/content-src/asrouter/docs
Messages
--------
There are JSON schemas for each type of message that the Firefox Messaging
System handles:
* `CFR URLBar Chiclet <cfr_urlbar_chiclet_schema_>`_
* `Extension Doorhanger <extension_doorhanger_schema_>`_
* `Infobar <infobar_schema_>`_
* `Spotlight <spotlight_schema_>`_
* `Toolbar Badge <toolbar_badge_schema_>`_
* `Update Action <update_action_schema_>`_
* `Whats New <whats_new_schema_>`_
* `Private Browsing Newtab Promo Message <pbnewtab_promo_schema_>`_
Together, they are combined into the `Messaging Experiments
<messaging_experiments_schema_>`_ via a `script <make_schemas_script_>`_. This
is the schema used for Nimbus experiments that target messaging features. All
incoming messaging experiments will be validated against these schemas.
To add a new message type to the Messaging Experiments schema:
1. Ensure the schema has an ``$id`` member. This allows for references (e.g.,
``{ "$ref": "#!/$defs/Foo" }``) to work in the bundled schema. See docs on
`bundling JSON schemas <jsonschema_bundling_>`_ for more information.
2. Add the new schema to the list in `make-schemas.py <make_schemas_script_>`_.
3. Build the new schema by running:
.. code-block:: shell
cd browser/components/newtab/schemas
../../../../../../mach python make-schemas.py
4. Commit the results.
Triggers and actions
---------------------
@ -16,3 +51,15 @@ Triggers and actions
SpecialMessageActionSchemas/index
TriggerActionSchemas/index
.. _cfr_urlbar_chiclet_schema: https://searchfox.org/mozilla-central/source/browser/components/newtab/content-src/asrouter/templates/CFR/templates/CFRUrlbarChiclet.schema.json
.. _extension_doorhanger_schema: https://searchfox.org/mozilla-central/source/browser/components/newtab/content-src/asrouter/templates/CFR/templates/ExtensionDoorhanger.schema.json
.. _infobar_schema: https://searchfox.org/mozilla-central/source/browser/components/newtab/content-src/asrouter/templates/CFR/templates/InfoBar.schema.json
.. _spotlight_schema: https://searchfox.org/mozilla-central/source/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/Spotlight.schema.json
.. _toolbar_badge_schema: https://searchfox.org/mozilla-central/source/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json
.. _update_action_schema: https://searchfox.org/mozilla-central/source/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json
.. _whats_new_schema: https://searchfox.org/mozilla-central/source/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json
.. _pbnewtab_promo_schema: https://searchfox.org/mozilla-central/source/browser/components/newtab/content-src/asrouter/templates/PBNewtab/NewtabPromoMessage.schema.json
.. _messaging_experiments_schema: https://searchfox.org/mozilla-central/source/browser/components/newtab/schemas/MessagingExperiment.schema.json
.. _make_schemas_script: https://searchfox.org/mozilla-central/source/browser/components/newtab/content-src/asrouter/schemas/make-schemas.py
.. _jsonschema_bundling: https://json-schema.org/understanding-json-schema/structuring.html#bundling

View File

@ -456,53 +456,53 @@ tcpByDefault:
type: boolean
description: "Enables TCP by default"
cfr:
description: "Doorhanger message template for Messaging System"
description: "A Firefox Messaging System message for the cfr message channel"
hasExposure: true
exposureDescription: "Exposure is sent if the message is about to be shown after trigger and targeting conditions on the message matched."
isEarlyStartup: false
schema:
uri: "resource://activity-stream/schemas/CFR/ExtensionDoorhanger.schema.json"
path: "browser/components/newtab/content-src/asrouter/templates/CFR/templates/ExtensionDoorhanger.schema.json"
uri: "resource://activity-stream/schemas/MessagingExperiment.schema.json"
path: "browser/components/newtab/schemas/MessagingExperiment.schema.json"
variables: {}
"moments-page":
description: "Message with URL data for Messaging System"
description: "A Firefox Messaging System message for the moments-page message channel"
hasExposure: true
exposureDescription: >-
"Exposure is sent if the message is about to be shown after trigger and targeting conditions on the message matched."
isEarlyStartup: false
schema:
uri: "resource://activity-stream/schemas/OnboardingMessage/UpdateAction.schema.json"
path: "browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json"
uri: "resource://activity-stream/schemas/MessagingExperiment.schema.json"
path: "browser/components/newtab/schemas/MessagingExperiment.schema.json"
variables: {}
infobar:
description: "Message template for Messaging System"
description: "A Firefox Messaging system message for the infobar message channel"
hasExposure: true
exposureDescription: >-
"Exposure is sent if the message is about to be shown after trigger and targeting conditions on the message matched."
isEarlyStartup: false
schema:
uri: "resource://activity-stream/schemas/CFR/InfoBar.schema.json"
path: "browser/components/newtab/content-src/asrouter/templates/CFR/templates/InfoBar.schema.json"
uri: "resource://activity-stream/schemas/MessagingExperiment.schema.json"
path: "browser/components/newtab/schemas/MessagingExperiment.schema.json"
variables: {}
spotlight:
description: "Modal message template for Messaging System"
description: "A Firefox Messaging System message for the spotlight message channel"
hasExposure: true
exposureDescription: >-
"Exposure is sent if the message is about to be shown after trigger and targeting conditions on the message matched."
isEarlyStartup: false
schema:
uri: "resource://activity-stream/schemas/OnboardingMessage/Spotlight.schema.json"
path: "browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/Spotlight.schema.json"
uri: "resource://activity-stream/schemas/MessagingExperiment.schema.json"
path: "browser/components/newtab/schemas/MessagingExperiment.schema.json"
variables: {}
pbNewtab:
description: Message shown on the PB newtab for Messaging System
description: "A Firefox Messaging System message for the pbNewtab message channel"
hasExposure: true
exposureDescription: >-
Exposure is sent if the message is about to be shown after trigger and targeting conditions on the message matched.
isEarlyStartup: false
schema:
uri: "resource://activity-stream/schemas/PBNewtab/NewtabPromoMessage.schema.json"
path: "browser/components/newtab/content-src/asrouter/templates/PBNewtab/NewtabPromoMessage.schema.json"
uri: "resource://activity-stream/schemas/MessagingExperiment.schema.json"
path: "browser/components/newtab/schemas/MessagingExperiment.schema.json"
variables: {}
syncAfterTabChange:
description: "Schedule a sync after any tab change"