commit 36a04696deb9fb880e2d0a8f6b53a27368336d15
parent 1dc0dfe400cfa1fc8c650283404e1499bdba475a
Author: Mike Conley <mconley@mozilla.com>
Date: Wed, 17 Dec 2025 16:19:03 +0000
Bug 2001387 - Part 3: Have AboutNewTabResourceMapping register a first-startup blocker for new profiles to install any potential train-hops. r=rpl
Differential Revision: https://phabricator.services.mozilla.com/D276296
Diffstat:
6 files changed, 559 insertions(+), 24 deletions(-)
diff --git a/browser/components/BrowserComponents.manifest b/browser/components/BrowserComponents.manifest
@@ -9,6 +9,8 @@
category app-startup nsBrowserGlue @mozilla.org/browser/browserglue;1 application={ec8030f7-c20a-464f-9b0e-13a3a9e97384} application={aa3c5121-dab2-40e2-81ca-7ea25febc110}
+category first-startup-new-profile resource:///modules/AboutNewTabResourceMapping.sys.mjs AboutNewTabResourceMapping.firstStartupNewProfile
+
# Browser global components initializing before UI startup
category browser-before-ui-startup resource:///modules/sessionstore/SessionStore.sys.mjs SessionStore.init
category browser-before-ui-startup resource:///modules/BuiltInThemes.sys.mjs BuiltInThemes.maybeInstallActiveBuiltInTheme
diff --git a/browser/components/newtab/AboutNewTabResourceMapping.sys.mjs b/browser/components/newtab/AboutNewTabResourceMapping.sys.mjs
@@ -9,6 +9,8 @@ export const BUILTIN_ADDON_ID = "newtab@mozilla.org";
export const DISABLE_NEWTAB_AS_ADDON_PREF =
"browser.newtabpage.disableNewTabAsAddon";
export const TRAINHOP_NIMBUS_FEATURE_ID = "newtabTrainhopAddon";
+export const TRAINHOP_NIMBUS_FIRST_STARTUP_FEATURE_ID =
+ "newtabTrainhopFirstStartup";
export const TRAINHOP_XPI_BASE_URL_PREF =
"browser.newtabpage.trainhopAddon.xpiBaseURL";
export const TRAINHOP_XPI_VERSION_PREF =
@@ -28,6 +30,7 @@ const lazy = XPCOMUtils.declareLazy({
AboutHomeStartupCache: "resource:///modules/AboutHomeStartupCache.sys.mjs",
AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+ ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs",
NewTabGleanUtils: "resource://newtab/lib/NewTabGleanUtils.sys.mjs",
NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
@@ -109,16 +112,7 @@ export var AboutNewTabResourceMapping = {
return;
}
- this.logger = console.createInstance({
- prefix: "AboutNewTabResourceMapping",
- maxLogLevel: Services.prefs.getBoolPref(
- "browser.newtabpage.resource-mapping.log",
- false
- )
- ? "Debug"
- : "Warn",
- });
- this.logger.debug("Initializing");
+ this.logger.debug("Initializing:");
// NOTE: this pref is read only once per session on purpose
// (and it is expected to be used by the resource mapping logic
@@ -341,13 +335,13 @@ export var AboutNewTabResourceMapping = {
registry.updateSources([newtabFileSource]);
this.logger.debug(
"Newtab strings updated for ",
- availableSupportedLocales
+ Array.from(availableSupportedLocales)
);
} else {
registry.registerSources([newtabFileSource]);
this.logger.debug(
"Newtab strings registered for ",
- availableSupportedLocales
+ Array.from(availableSupportedLocales)
);
}
},
@@ -428,7 +422,7 @@ export var AboutNewTabResourceMapping = {
* installed or pending to be installed). Rejects on failures or unexpected cancellations
* during installation or uninstallation process.
*/
- async updateTrainhopAddonState() {
+ async updateTrainhopAddonState(forceRestartlessInstall = false) {
if (this.inSafeMode) {
this.logger.debug(
"train-hop add-on update state disabled while running in SafeMode"
@@ -442,6 +436,10 @@ export var AboutNewTabResourceMapping = {
defaultValues: { addon_version: null, xpi_download_path: null },
});
+ this.logger.debug("Force restartless install: ", forceRestartlessInstall);
+ this.logger.debug("Received addon version:", addon_version);
+ this.logger.debug("Received XPI download path:", xpi_download_path);
+
let addon = await lazy.AddonManager.getAddonByID(BUILTIN_ADDON_ID);
// Uninstall train-hop add-on xpi if its resources are not currently
@@ -535,6 +533,7 @@ export var AboutNewTabResourceMapping = {
await this._installTrainhopAddon({
trainhopAddonVersion: addon_version,
xpiDownloadURL,
+ forceRestartlessInstall,
});
},
@@ -545,12 +544,21 @@ export var AboutNewTabResourceMapping = {
* @param {object} params
* @param {string} params.trainhopAddonVersion - The version of the train-hop add-on to install.
* @param {string} params.xpiDownloadURL - The URL from which to download the XPI file.
+ * @param {boolean} params.forceRestartlessInstall
+ * After the XPI is downloaded, attempt to complete a restartless install. Note that if
+ * AboutNewTabResourceMapping.init has been called by the time the XPI has finished
+ * downloading, this directive is ignored, and we fallback to installing on the next
+ * restart.
*
* @returns {Promise<void>}
* Resolves when the train-hop add-on installation is completed or not needed, or rejects
* on failures or unexpected cancellations hit during the installation process.
*/
- async _installTrainhopAddon({ trainhopAddonVersion, xpiDownloadURL }) {
+ async _installTrainhopAddon({
+ trainhopAddonVersion,
+ xpiDownloadURL,
+ forceRestartlessInstall,
+ }) {
if (
this._builtinVersion &&
Services.vc.compare(this._builtinVersion, trainhopAddonVersion) >= 0
@@ -603,7 +611,7 @@ export var AboutNewTabResourceMapping = {
);
const deferred = Promise.withResolvers();
newInstall.addListener({
- onDownloadEnded() {
+ onDownloadEnded: () => {
if (
newInstall.addon.id !== BUILTIN_ADDON_ID ||
newInstall.addon.version !== trainhopAddonVersion
@@ -630,30 +638,51 @@ export var AboutNewTabResourceMapping = {
);
newInstall.cancel();
}
+
+ this.logger.debug("Train-hop download ended");
+ },
+ onInstallPostponed: () => {
+ this.logger.debug("Train-hop install postponed, as expected");
+ if (forceRestartlessInstall && !this.initialized) {
+ this.logger.debug("Forcing restartless install of train-hop");
+ newInstall.continuePostponedInstall();
+ } else {
+ this.logger.debug("Not forcing restartless install");
+ if (forceRestartlessInstall) {
+ this.logger.debug(
+ "We must have initialized before the XPI finished downloading."
+ );
+ }
+ deferred.resolve();
+ }
},
- onInstallPostponed() {
- deferred.resolve();
+ onInstallEnded: () => {
+ this.logger.debug("Train-hop restartless install ended");
+ if (forceRestartlessInstall) {
+ this.logger.debug("Resolving train-hop install promise");
+ deferred.resolve();
+ }
},
- onDownloadCancelled() {
+ onDownloadCancelled: () => {
deferred.reject(
new Error(
`Unexpected download cancelled while downloading xpi from ${xpiDownloadURL}`
)
);
},
- onDownloadFailed() {
+ onDownloadFailed: () => {
deferred.reject(
new Error(`Failed to download xpi from ${xpiDownloadURL}`)
);
},
- onInstallCancelled() {
+ onInstallCancelled: () => {
deferred.reject(
new Error(
`Unexpected install cancelled while installing xpi from ${xpiDownloadURL}`
)
);
},
- onInstallFailed() {
+ onInstallFailed: () => {
deferred.reject(
new Error(`Failed to install xpi from ${xpiDownloadURL}`)
);
@@ -661,9 +690,16 @@ export var AboutNewTabResourceMapping = {
});
newInstall.install();
await deferred.promise;
- this.logger.debug(
- `train-hop add-on ${trainhopAddonVersion} downloaded and pending install on next startup`
- );
+
+ if (forceRestartlessInstall) {
+ this.logger.debug(
+ `train-hop add-on ${trainhopAddonVersion} downloaded and we will attempt a restartless install`
+ );
+ } else {
+ this.logger.debug(
+ `train-hop add-on ${trainhopAddonVersion} downloaded and pending install on next startup`
+ );
+ }
} catch (e) {
this.logger.error(`train-hop add-on install failure: ${e}`);
}
@@ -694,4 +730,54 @@ export var AboutNewTabResourceMapping = {
}
return false;
},
+
+ /**
+ * This is registered to be called on first startup for new profiles on
+ * Windows. It is expected to be called very early on in the lifetime of
+ * new profiles, such that the AboutNewTabResourceMapping.init routine has
+ * not yet had a chance to run.
+ *
+ * @returns {Promise<void>}
+ */
+ async firstStartupNewProfile() {
+ if (this.initialized) {
+ this.logger.error(
+ "firstStartupNewProfile is being run after AboutNewTabResourceMapping initializes, so we're too late."
+ );
+ return;
+ }
+ this.logger.debug(
+ "First startup with a new profile. Checking for any train-hops to perform restartless install."
+ );
+ await lazy.ExperimentAPI.ready();
+
+ const nimbusFeature =
+ lazy.NimbusFeatures[TRAINHOP_NIMBUS_FIRST_STARTUP_FEATURE_ID];
+ await nimbusFeature.ready();
+ const { enabled } = nimbusFeature.getAllVariables({
+ defaultValues: { enabled: true },
+ });
+ if (!enabled) {
+ // We've been configured to bypass the FirstStartup install for
+ // train-hops, so exit now.
+ this.logger.debug(
+ "Not forcing install of any newtab XPIs, as we're currently configured not to."
+ );
+ return;
+ }
+
+ await lazy.AddonManager.readyPromise;
+ await this.updateTrainhopAddonState(true /* forceRestartlessInstall */);
+ this.logger.debug("First startup - new profile done");
+ },
};
+
+AboutNewTabResourceMapping.logger = console.createInstance({
+ prefix: "AboutNewTabResourceMapping",
+ maxLogLevel: Services.prefs.getBoolPref(
+ "browser.newtabpage.resource-mapping.log",
+ false
+ )
+ ? "Debug"
+ : "Warn",
+});
diff --git a/browser/components/newtab/test/xpcshell/head_nimbus_trainhop.js b/browser/components/newtab/test/xpcshell/head_nimbus_trainhop.js
@@ -25,6 +25,7 @@ const {
AboutNewTabResourceMapping,
BUILTIN_ADDON_ID,
TRAINHOP_NIMBUS_FEATURE_ID,
+ TRAINHOP_NIMBUS_FIRST_STARTUP_FEATURE_ID,
TRAINHOP_XPI_BASE_URL_PREF,
TRAINHOP_SCHEDULED_UPDATE_STATE_TIMEOUT_PREF,
TRAINHOP_SCHEDULED_UPDATE_STATE_DELAY_PREF,
diff --git a/browser/components/newtab/test/xpcshell/test_nimbus_newtabTrainhopAddon_firstStartup.js b/browser/components/newtab/test/xpcshell/test_nimbus_newtabTrainhopAddon_firstStartup.js
@@ -0,0 +1,429 @@
+/* Any copyright is dedicated to the Public Domain.
+https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/* import-globals-from ../../../../extensions/newtab/test/xpcshell/head.js */
+
+/* import-globals-from head_nimbus_trainhop.js */
+
+const { AboutHomeStartupCache } = ChromeUtils.importESModule(
+ "resource:///modules/AboutHomeStartupCache.sys.mjs"
+);
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+const { FirstStartup } = ChromeUtils.importESModule(
+ "resource://gre/modules/FirstStartup.sys.mjs"
+);
+const { updateAppInfo } = ChromeUtils.importESModule(
+ "resource://testing-common/AppInfo.sys.mjs"
+);
+
+const PREF_CATEGORY_TASKS = "first-startup.category-tasks-enabled";
+const CATEGORY_NAME = "first-startup-new-profile";
+
+add_setup(async () => {
+ Services.fog.testResetFOG();
+ updateAppInfo();
+});
+
+/**
+ * Test that AboutNewTabResourceMapping has a first-startup-new-profile
+ * category entry registered for it for the
+ * AboutNewTabResourceMapping.firstStartupNewProfile method.
+ */
+add_task(async function test_is_firstStartupNewProfile_registered() {
+ const entry = Services.catMan.getCategoryEntry(
+ CATEGORY_NAME,
+ "resource:///modules/AboutNewTabResourceMapping.sys.mjs"
+ );
+ Assert.ok(
+ entry,
+ "An entry should exist for resource:///modules/AboutNewTabResourceMapping.sys.mjs"
+ );
+ Assert.equal(
+ entry,
+ "AboutNewTabResourceMapping.firstStartupNewProfile",
+ "Entry value should point to the `firstStartupNewProfile` method"
+ );
+});
+
+/**
+ * Test that the firstStartupNewProfile hook gets called during FirstStartup
+ * and performs a restartless install of a train-hop add-on when Nimbus is
+ * configured with one.
+ */
+add_task(
+ { skip_if: () => !AppConstants.MOZ_NORMANDY },
+ async function test_firstStartup_trainhop_restartless_install() {
+ // Enable category tasks for first startup
+ Services.prefs.setBoolPref(PREF_CATEGORY_TASKS, true);
+ FirstStartup.resetForTesting();
+
+ // Reset AboutNewTabResourceMapping state so firstStartupNewProfile can run
+ mockAboutNewTabUninit();
+
+ // Sanity check - verify built-in add-on resources have been mapped
+ assertNewTabResourceMapping();
+ await asyncAssertNewTabAddon({
+ locationName: BUILTIN_LOCATION_NAME,
+ });
+ assertTrainhopAddonNimbusExposure({ expectedExposure: false });
+
+ const updateAddonVersion = `${BUILTIN_ADDON_VERSION}.123`;
+
+ const { nimbusFeatureCleanup } = await setupNimbusTrainhopAddon({
+ updateAddonVersion,
+ });
+ assertTrainhopAddonVersionPref(updateAddonVersion);
+
+ // Track whether firstStartupNewProfile was called
+ let sandbox = sinon.createSandbox();
+ let firstStartupNewProfileSpy = sandbox.spy(
+ AboutNewTabResourceMapping,
+ "firstStartupNewProfile"
+ );
+ let aboutHomeStartupClearCacheStub = sandbox.stub(
+ AboutHomeStartupCache,
+ "clearCacheAndUninit"
+ );
+
+ let submissionPromise = new Promise(resolve => {
+ GleanPings.firstStartup.testBeforeNextSubmit(() => {
+ Assert.equal(FirstStartup.state, FirstStartup.SUCCESS);
+ resolve();
+ });
+ });
+
+ // Run FirstStartup which should trigger our category hook
+ FirstStartup.init(true /* newProfile */);
+
+ await submissionPromise;
+
+ Assert.ok(
+ firstStartupNewProfileSpy.calledOnce,
+ "firstStartupNewProfile should have been called"
+ );
+ Assert.ok(
+ aboutHomeStartupClearCacheStub.calledOnce,
+ "AboutHomeStartupCache.clearCacheAndUninit called after installing train-hop"
+ );
+
+ // The train-hop add-on should have been installed restartlessly
+ let addon = await asyncAssertNewTabAddon({
+ locationName: PROFILE_LOCATION_NAME,
+ version: updateAddonVersion,
+ });
+
+ Assert.ok(addon, "Train-hop add-on should be installed");
+
+ // No pending installs should remain since we did a restartless install
+ Assert.deepEqual(
+ await AddonManager.getAllInstalls(),
+ [],
+ "Expect no pending install for restartless install"
+ );
+
+ sandbox.restore();
+
+ // Uninstall the force-installed train-hop add-on before cleanup
+ await addon.uninstall();
+
+ await nimbusFeatureCleanup();
+ assertTrainhopAddonVersionPref("");
+ Services.prefs.clearUserPref(PREF_CATEGORY_TASKS);
+ }
+);
+
+/**
+ * Test that if AboutNewTabResourceMapping.init() has already been called
+ * by the time firstStartupNewProfile runs, it logs an error and exits early.
+ * This is not an expected or realistic condition, but we cover it all the same.
+ */
+add_task(
+ { skip_if: () => !AppConstants.MOZ_NORMANDY },
+ async function test_firstStartup_after_initialization() {
+ // Initialize AboutNewTabResourceMapping before FirstStartup runs.
+ AboutNewTabResourceMapping.init();
+ Assert.ok(
+ AboutNewTabResourceMapping.initialized,
+ "AboutNewTabResourceMapping should be initialized"
+ );
+
+ Services.prefs.setBoolPref(PREF_CATEGORY_TASKS, true);
+ FirstStartup.resetForTesting();
+
+ const updateAddonVersion = `${BUILTIN_ADDON_VERSION}.456`;
+
+ const { nimbusFeatureCleanup } = await setupNimbusTrainhopAddon({
+ updateAddonVersion,
+ });
+
+ // Track error logging
+ let errorLogged = false;
+ let sandbox = sinon.createSandbox();
+ sandbox.stub(AboutNewTabResourceMapping.logger, "error").callsFake(() => {
+ errorLogged = true;
+ });
+
+ let submissionPromise = new Promise(resolve => {
+ GleanPings.firstStartup.testBeforeNextSubmit(() => {
+ resolve();
+ });
+ });
+
+ FirstStartup.init(true /* newProfile */);
+ await submissionPromise;
+
+ Assert.ok(
+ errorLogged,
+ "An error should have been logged when trying to run after initialization"
+ );
+
+ // The add-on should NOT have been installed since we were too late
+ await asyncAssertNewTabAddon({
+ locationName: BUILTIN_LOCATION_NAME,
+ version: BUILTIN_ADDON_VERSION,
+ });
+
+ sandbox.restore();
+ await nimbusFeatureCleanup();
+ Services.prefs.clearUserPref(PREF_CATEGORY_TASKS);
+ }
+);
+
+/**
+ * Test that firstStartupNewProfile doesn't run when the category tasks pref
+ * is disabled.
+ */
+add_task(
+ { skip_if: () => !AppConstants.MOZ_NORMANDY },
+ async function test_firstStartup_category_disabled() {
+ // Disable category tasks
+ Services.prefs.setBoolPref(PREF_CATEGORY_TASKS, false);
+ FirstStartup.resetForTesting();
+
+ // Reset AboutNewTabResourceMapping state
+ mockAboutNewTabUninit();
+
+ const updateAddonVersion = `${BUILTIN_ADDON_VERSION}.789`;
+
+ const { nimbusFeatureCleanup } = await setupNimbusTrainhopAddon({
+ updateAddonVersion,
+ });
+
+ let sandbox = sinon.createSandbox();
+ let firstStartupNewProfileSpy = sandbox.spy(
+ AboutNewTabResourceMapping,
+ "firstStartupNewProfile"
+ );
+
+ let submissionPromise = new Promise(resolve => {
+ GleanPings.firstStartup.testBeforeNextSubmit(() => {
+ resolve();
+ });
+ });
+
+ FirstStartup.init(true /* newProfile */);
+ await submissionPromise;
+
+ Assert.ok(
+ !firstStartupNewProfileSpy.called,
+ "firstStartupNewProfile should not have been called when pref is disabled"
+ );
+
+ // The add-on should still be the builtin version
+ await asyncAssertNewTabAddon({
+ locationName: BUILTIN_LOCATION_NAME,
+ version: BUILTIN_ADDON_VERSION,
+ });
+
+ sandbox.restore();
+ await nimbusFeatureCleanup();
+ Services.prefs.clearUserPref(PREF_CATEGORY_TASKS);
+ }
+);
+
+/**
+ * Test that if AboutNewTabResourceMapping.init() is called after the XPI
+ * download has started but before onInstallPostponed is called, we skip
+ * attempting to force the restartless install and fall back to a staged
+ * install instead.
+ */
+add_task(
+ { skip_if: () => !AppConstants.MOZ_NORMANDY },
+ async function test_firstStartup_init_during_download() {
+ Services.prefs.setBoolPref(PREF_CATEGORY_TASKS, true);
+ FirstStartup.resetForTesting();
+
+ // Reset AboutNewTabResourceMapping state so firstStartupNewProfile can run
+ mockAboutNewTabUninit();
+
+ assertNewTabResourceMapping();
+ await asyncAssertNewTabAddon({
+ locationName: BUILTIN_LOCATION_NAME,
+ });
+
+ const updateAddonVersion = `${BUILTIN_ADDON_VERSION}.999`;
+
+ const { nimbusFeatureCleanup } = await setupNimbusTrainhopAddon({
+ updateAddonVersion,
+ });
+ assertTrainhopAddonVersionPref(updateAddonVersion);
+
+ // Stub updateTrainhopAddonState to call init() in the middle of its execution
+ let sandbox = sinon.createSandbox();
+ let aboutNewTabInitSpy = sandbox.spy(AboutNewTabResourceMapping, "init");
+
+ let originalUpdateTrainhopAddonState =
+ AboutNewTabResourceMapping.updateTrainhopAddonState.bind(
+ AboutNewTabResourceMapping
+ );
+
+ let updateTrainhopStarted = false;
+ sandbox
+ .stub(AboutNewTabResourceMapping, "updateTrainhopAddonState")
+ .callsFake(async function (forceRestartlessInstall) {
+ updateTrainhopStarted = true;
+
+ // Start the update process
+ let updatePromise = originalUpdateTrainhopAddonState(
+ forceRestartlessInstall
+ );
+
+ // Call init immediately after starting the update, simulating
+ // the browser window opening during the XPI download
+ info(
+ "Calling AboutNewTabResourceMapping.init() during updateTrainhopAddonState"
+ );
+ AboutNewTabResourceMapping.init();
+
+ // Wait for the update to complete
+ await updatePromise;
+ });
+
+ let submissionPromise = new Promise(resolve => {
+ GleanPings.firstStartup.testBeforeNextSubmit(() => {
+ Assert.equal(FirstStartup.state, FirstStartup.SUCCESS);
+ resolve();
+ });
+ });
+
+ FirstStartup.init(true /* newProfile */);
+ await submissionPromise;
+
+ Assert.ok(
+ updateTrainhopStarted,
+ "updateTrainhopAddonState should have started"
+ );
+ Assert.ok(
+ aboutNewTabInitSpy.calledOnce,
+ "AboutNewTabResourceMapping.init should have been called"
+ );
+
+ // The add-on should be staged for install, not installed restartlessly
+ await asyncAssertNewTabAddon({
+ locationName: BUILTIN_LOCATION_NAME,
+ version: BUILTIN_ADDON_VERSION,
+ });
+
+ // Verify there's a pending install
+ const pendingInstall = (await AddonManager.getAllInstalls()).find(
+ install => install.addon.id === BUILTIN_ADDON_ID
+ );
+ Assert.ok(pendingInstall, "Should have a pending install");
+ Assert.equal(
+ pendingInstall.state,
+ AddonManager.STATE_POSTPONED,
+ "Install should be postponed"
+ );
+ Assert.equal(
+ pendingInstall.addon.version,
+ updateAddonVersion,
+ "Pending install should be for the train-hop version"
+ );
+
+ // Clean up
+ await cancelPendingInstall(pendingInstall);
+ sandbox.restore();
+ await nimbusFeatureCleanup();
+ assertTrainhopAddonVersionPref("");
+ Services.prefs.clearUserPref(PREF_CATEGORY_TASKS);
+ }
+);
+
+/**
+ * Test that the TRAINHOP_NIMBUS_FIRST_STARTUP_FEATURE_ID Nimbus feature can be
+ * used to remotely disable the FirstStartup force-install flow.
+ */
+add_task(
+ { skip_if: () => !AppConstants.MOZ_NORMANDY },
+ async function test_firstStartup_remote_disable() {
+ // Enable category tasks for first startup
+ Services.prefs.setBoolPref(PREF_CATEGORY_TASKS, true);
+ FirstStartup.resetForTesting();
+
+ // Reset AboutNewTabResourceMapping state so firstStartupNewProfile can run
+ mockAboutNewTabUninit();
+
+ // Sanity check - verify built-in add-on resources have been mapped
+ assertNewTabResourceMapping();
+ await asyncAssertNewTabAddon({
+ locationName: BUILTIN_LOCATION_NAME,
+ });
+ assertTrainhopAddonNimbusExposure({ expectedExposure: false });
+
+ const updateAddonVersion = `${BUILTIN_ADDON_VERSION}.123`;
+
+ const { nimbusFeatureCleanup } = await setupNimbusTrainhopAddon({
+ updateAddonVersion,
+ });
+ assertTrainhopAddonVersionPref(updateAddonVersion);
+
+ const firstStartupFeatureCleanup =
+ await NimbusTestUtils.enrollWithFeatureConfig(
+ {
+ featureId: TRAINHOP_NIMBUS_FIRST_STARTUP_FEATURE_ID,
+ value: { enabled: false },
+ },
+ { isRollout: true }
+ );
+
+ // Track whether firstStartupNewProfile was called
+ let sandbox = sinon.createSandbox();
+ let firstStartupNewProfileSpy = sandbox.spy(
+ AboutNewTabResourceMapping,
+ "firstStartupNewProfile"
+ );
+
+ let submissionPromise = new Promise(resolve => {
+ GleanPings.firstStartup.testBeforeNextSubmit(() => {
+ Assert.equal(FirstStartup.state, FirstStartup.SUCCESS);
+ resolve();
+ });
+ });
+
+ // Run FirstStartup which should trigger our category hook
+ FirstStartup.init(true /* newProfile */);
+
+ await submissionPromise;
+
+ Assert.ok(
+ firstStartupNewProfileSpy.calledOnce,
+ "firstStartupNewProfile should have been called"
+ );
+
+ // The add-on should still be the builtin version
+ await asyncAssertNewTabAddon({
+ locationName: BUILTIN_LOCATION_NAME,
+ version: BUILTIN_ADDON_VERSION,
+ });
+
+ sandbox.restore();
+ await nimbusFeatureCleanup();
+ await firstStartupFeatureCleanup();
+ assertTrainhopAddonVersionPref("");
+ Services.prefs.clearUserPref(PREF_CATEGORY_TASKS);
+ }
+);
diff --git a/browser/components/newtab/test/xpcshell/xpcshell.toml b/browser/components/newtab/test/xpcshell/xpcshell.toml
@@ -24,5 +24,8 @@ skip-if = [
"os == 'linux' && os_version == '24.04' && arch == 'x86_64' && display == 'x11' && tsan", # Bug 1989373
]
+["test_nimbus_newtabTrainhopAddon_firstStartup.js"]
+head = "../../../../extensions/newtab/test/xpcshell/head.js head_nimbus_trainhop.js"
+
["test_nimbus_newtabTrainhopAddon_onBrowserReady.js"]
head = "../../../../extensions/newtab/test/xpcshell/head.js head_nimbus_trainhop.js"
diff --git a/toolkit/components/nimbus/FeatureManifest.yaml b/toolkit/components/nimbus/FeatureManifest.yaml
@@ -1611,6 +1611,20 @@ newtabTrainhopAddon:
branch: user
pref: browser.newtabpage.trainhopAddon.version
+newtabTrainhopFirstStartup:
+ description: |
+ Controls startup behaviours if and when a train-hop is available for a
+ fresh install.
+ owner: mconley@mozilla.com
+ hasExposure: false
+ variables:
+ enabled:
+ type: boolean
+ description: |
+ True if New Tab should attempt to download, install and enable
+ train-hopped newtab XPIs for the first profile of new installs on
+ Windows. This is true by default, but can be set to false to disable.
+
newtabTopicSelection:
description: the about:newtab topic selection experience.
owner: nbarrett@mozilla.com