diff --git a/.github/workflows/functional_tests.yaml b/.github/workflows/functional_tests.yaml index 7b947eedf4..32a6097488 100644 --- a/.github/workflows/functional_tests.yaml +++ b/.github/workflows/functional_tests.yaml @@ -23,7 +23,7 @@ jobs: steps: - name: Clone repository uses: actions/checkout@v3 - with: + with: submodules: 'true' - name: Install build dependecies @@ -87,7 +87,7 @@ jobs: functionaltests: name: Functional tests - needs: + needs: - build_test_app runs-on: ubuntu-22.04 timeout-minutes: 45 @@ -143,7 +143,7 @@ jobs: export HEADLESS=yes export TZ=Europe/London mkdir -p $ARTIFACT_DIR - xvfb-run -a npm run functionalTest -- ${{matrix.test.path}} + xvfb-run -a npm run functionalTest -- --retries 3 ${{matrix.test.path}} env: ARTIFACT_DIR: ${{ runner.temp }}/artifacts MVPN_BIN: ./build/dummyvpn diff --git a/.github/workflows/wasm.yaml b/.github/workflows/wasm.yaml index bf50938656..3449125370 100644 --- a/.github/workflows/wasm.yaml +++ b/.github/workflows/wasm.yaml @@ -26,7 +26,7 @@ jobs: steps: - name: Clone repository uses: actions/checkout@v3 - with: + with: submodules: 'true' - name: Install Qt @@ -122,8 +122,8 @@ jobs: with: name: WebAssembly Build Qt6 # Destination path - path: wasm - + path: wasm + - name: Build addons shell: bash run: ./scripts/addon/generate_all_tests.py -q /opt/$QTVERSION/gcc_64/bin @@ -137,4 +137,4 @@ jobs: command: | export PATH=$GECKOWEBDRIVER:$(npm bin):$PATH export HEADLESS=yes - xvfb-run -a npm run functionalTestWasm -- ${{matrix.test.path}} + xvfb-run -a npm run functionalTestWasm -- --retries 3 ${{matrix.test.path}} diff --git a/addons/message_subscription_expiring/contactSupportLink.js b/addons/message_subscription_expiring/contactSupportLink.js new file mode 100644 index 0000000000..79dfebded1 --- /dev/null +++ b/addons/message_subscription_expiring/contactSupportLink.js @@ -0,0 +1,3 @@ +((api) => { + api.urlOpener.openLink(api.urlOpener.LinkHelpSupport); +}); diff --git a/addons/message_subscription_expiring/isExpiring.js b/addons/message_subscription_expiring/isExpiring.js new file mode 100644 index 0000000000..6f2f2db2df --- /dev/null +++ b/addons/message_subscription_expiring/isExpiring.js @@ -0,0 +1,13 @@ +(function(api, condition) { + // Show message only if within 1 week of expiring. + const weekBeforeExpireMSecs = api.subscriptionData.expiresOn - 1000 * 60 * 60 * 24 * 7; + const subscriptionExpiry = api.subscriptionData.expiresOn; + const now = Date.now(); + + if (now < subscriptionExpiry && now >= weekBeforeExpireMSecs) { + api.addon.date = weekBeforeExpireMSecs; + condition.enable(); + } else { + condition.disable(); + } +}); diff --git a/addons/message_subscription_expiring/manageAccountLink.js b/addons/message_subscription_expiring/manageAccountLink.js new file mode 100644 index 0000000000..8548799870 --- /dev/null +++ b/addons/message_subscription_expiring/manageAccountLink.js @@ -0,0 +1,3 @@ +((api) => { + api.urlOpener.openLink(api.urlOpener.LinkAccount); +}); diff --git a/addons/message_subscription_expiring/manifest.json b/addons/message_subscription_expiring/manifest.json new file mode 100644 index 0000000000..e70d6ea554 --- /dev/null +++ b/addons/message_subscription_expiring/manifest.json @@ -0,0 +1,37 @@ +{ + "api_version": "0.1", + "id": "message_subscription_expiring", + "name": "Subscription expiring message", + "type": "message", + "conditions": { + "javascript": "isExpiring.js", + "min_client_version": "2.15.0" + }, + "message": { + "id": "message_subscription_expiring", + "title": "Your subscription is ending soon", + "subtitle": "Your Mozilla VPN subscription will end in one week. After that, you’ll no longer be able to use Mozilla VPN.", + "badge": "warning", + "blocks": [ + { + "id": "c_1", + "type": "text", + "content": "Don’t lose your subscription — please go to your account page to renew your access to Mozilla VPN. If you’re having any issues, please contact support to get help." + }, + { + "id": "c_2", + "type": "button", + "style": "primary", + "content": "Manage account", + "javascript": "manageAccountLink.js" + }, + { + "id": "c_3", + "type": "button", + "style": "link", + "content": "Contact support", + "javascript": "contactSupportLink.js" + } + ] + } +} diff --git a/package.json b/package.json index e3cd766bf3..f19cf578a4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "scripts": { - "functionalTest": "mocha --require ./tests/functional/setupVpn.js --timeout 30000 --retries 3", - "functionalTestWasm": "mocha --require ./tests/functional/setupWasm.js --timeout 30000 --retries 3" + "functionalTest": "mocha --require ./tests/functional/setupVpn.js --timeout 30000", + "functionalTestWasm": "mocha --require ./tests/functional/setupWasm.js --timeout 30000" }, "devDependencies": { "body-parser": "^1.20.0", diff --git a/scripts/addon/generate_all_tests.py b/scripts/addon/generate_all_tests.py index 798a3a0c32..271c1fbb9b 100755 --- a/scripts/addon/generate_all_tests.py +++ b/scripts/addon/generate_all_tests.py @@ -19,15 +19,23 @@ args = parser.parse_args() generateall_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "generate_all.py") -addons_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "..", "tests", "functional", "addons") -for file in os.listdir(addons_path): - manifest_path = os.path.join(addons_path, file, "manifest.json") +# Generate production addons files +build_cmd = [sys.executable, generateall_path] +if args.qtpath: + build_cmd.append("-q") + build_cmd.append(args.qtpath) +subprocess.call(build_cmd) + +# Generate test addons files +test_addons_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "..", "tests", "functional", "addons") +for file in os.listdir(test_addons_path): + manifest_path = os.path.join(test_addons_path, file, "manifest.json") if os.path.exists(manifest_path): print(f"Ignoring path {file} because the manifest already exists.") continue - build_cmd = [sys.executable, generateall_path, "-p", os.path.join(addons_path, file)] + build_cmd = [sys.executable, generateall_path, "-p", os.path.join(test_addons_path, file)] if args.qtpath: build_cmd.append("-q") build_cmd.append(args.qtpath) diff --git a/src/apps/vpn/inspector/inspectorhandler.cpp b/src/apps/vpn/inspector/inspectorhandler.cpp index 67c775a22f..acafcb3d03 100644 --- a/src/apps/vpn/inspector/inspectorhandler.cpp +++ b/src/apps/vpn/inspector/inspectorhandler.cpp @@ -567,6 +567,24 @@ static QList s_commands{ return obj; }}, + InspectorCommand{"messages", "Returns a list of the loaded messages ids", 0, + [](InspectorHandler*, const QList&) { + QJsonObject obj; + + AddonManager* am = AddonManager::instance(); + Q_ASSERT(am); + + QJsonArray messages; + am->forEach([&](Addon* addon) { + if (addon->type() == "message") { + messages.append(addon->id()); + } + }); + + obj["value"] = messages; + return obj; + }}, + InspectorCommand{"translate", "Translate a string", 1, [](InspectorHandler*, const QList& arguments) { QJsonObject obj; diff --git a/tests/functional/helper.js b/tests/functional/helper.js index c6010bda85..48dde4bbde 100644 --- a/tests/functional/helper.js +++ b/tests/functional/helper.js @@ -499,6 +499,14 @@ module.exports = { return json.value; }, + async messages() { + const json = await this._writeCommand('messages'); + assert( + json.type === 'messages' && !('error' in json), + `Command failed: ${json.error}`); + return json.value; + }, + async screenCapture() { const json = await this._writeCommand('screen_capture'); assert( diff --git a/tests/functional/servers/addon.js b/tests/functional/servers/addon.js index c51702a0eb..3ed1337d65 100644 --- a/tests/functional/servers/addon.js +++ b/tests/functional/servers/addon.js @@ -6,7 +6,8 @@ const Server = require('./server.js'); const fs = require('fs'); const path = require('path'); -const ADDON_PATH = './tests/functional/addons'; +const TEST_ADDONS_PATH = './tests/functional/addons'; +const PROD_ADDONS_PATH = './addons'; // This function exposes all the files for a particular addon scenario through // the addon server. @@ -15,8 +16,7 @@ function createScenario(scenario, addonPath) { if (!fs.existsSync(generatedPath)) { const manifestPath = path.join(addonPath, 'manifest.json'); if (!fs.existsSync(manifestPath)) { - throw new Error(`No generated and not manifest file! ${ - manifestPath} should exist! Have you executed \`./scripts/addon/generate_all_tests.py'?`); + throw new Error(`No generated and not manifest file! ${manifestPath} should exist! Have you executed \`./scripts/addon/generate_all_tests.py'?`); } const obj = {}; @@ -65,17 +65,19 @@ function createScenario(scenario, addonPath) { let server = null; module.exports = { async start(headerCheck = true) { - let scenarios = {}; + // Generate production addon scenarios + let scenarios = { ...createScenario("prod", PROD_ADDONS_PATH) }; - const dirs = fs.readdirSync(ADDON_PATH); + // Generate test addon scenarios + const dirs = fs.readdirSync(TEST_ADDONS_PATH); for (const dir of dirs) { - const addonPath = path.join(ADDON_PATH, dir); + const addonPath = path.join(TEST_ADDONS_PATH, dir); const stat = fs.statSync(addonPath); if (!stat.isDirectory()) { continue; } - scenarios = {...scenarios, ...createScenario(dir, addonPath)}; + scenarios = { ...scenarios, ...createScenario(dir, addonPath) }; } const endpoints = { diff --git a/tests/functional/servers/guardian_endpoints.js b/tests/functional/servers/guardian_endpoints.js index 0468b73307..dffabb0711 100644 --- a/tests/functional/servers/guardian_endpoints.js +++ b/tests/functional/servers/guardian_endpoints.js @@ -39,6 +39,8 @@ const SubscriptionDetails = { }, }; +exports.SubscriptionDetails = SubscriptionDetails; + const VALIDATORS = { guardianLoginVerify: { type: 'object', diff --git a/tests/functional/setupVpn.js b/tests/functional/setupVpn.js index 68d023b205..449ab815ec 100644 --- a/tests/functional/setupVpn.js +++ b/tests/functional/setupVpn.js @@ -46,6 +46,8 @@ async function startAndConnect() { await vpn.connect(vpnWS, {hostname: '127.0.0.1'}); } +exports.startAndConnect = startAndConnect; + exports.mochaHooks = { async beforeAll() { // Check VPN app exists. If not, bail. diff --git a/tests/functional/testAddons.js b/tests/functional/testAddons.js index 181ceef07a..cc16564b50 100644 --- a/tests/functional/testAddons.js +++ b/tests/functional/testAddons.js @@ -5,16 +5,18 @@ const assert = require('assert'); const queries = require('./queries.js'); const vpn = require('./helper.js'); +const { SubscriptionDetails } = require('./servers/guardian_endpoints.js'); +const { env, TestingEnvironments } = require('./helper.js'); -describe('Addons', function() { +describe('Addons', function () { this.ctx.authenticationNeeded = true; it('Empty addon index', async () => { await vpn.resetAddons('01_empty_manifest'); await vpn.waitForCondition(async () => { return parseInt( - await vpn.getVPNProperty('VPNAddonManager', 'count'), 10) === - 0; + await vpn.getVPNProperty('VPNAddonManager', 'count'), 10) === + 0; }); }); @@ -22,15 +24,15 @@ describe('Addons', function() { await vpn.resetAddons('03_single_addon'); await vpn.waitForCondition(async () => { return parseInt( - await vpn.getVPNProperty('VPNAddonManager', 'count'), 10) === - 1; + await vpn.getVPNProperty('VPNAddonManager', 'count'), 10) === + 1; }); await vpn.fetchAddons('02_broken_manifest'); await vpn.waitForCondition(async () => { return parseInt( - await vpn.getVPNProperty('VPNAddonManager', 'count'), 10) === - 1; + await vpn.getVPNProperty('VPNAddonManager', 'count'), 10) === + 1; }); }); @@ -38,22 +40,22 @@ describe('Addons', function() { await vpn.resetAddons('03_single_addon'); await vpn.waitForCondition(async () => { return parseInt( - await vpn.getVPNProperty('VPNAddonManager', 'count'), 10) === - 1; + await vpn.getVPNProperty('VPNAddonManager', 'count'), 10) === + 1; }); await vpn.fetchAddons('01_empty_manifest'); await vpn.waitForCondition(async () => { return parseInt( - await vpn.getVPNProperty('VPNAddonManager', 'count'), 10) === - 0; + await vpn.getVPNProperty('VPNAddonManager', 'count'), 10) === + 0; }); await vpn.fetchAddons('03_single_addon'); await vpn.waitForCondition(async () => { return parseInt( - await vpn.getVPNProperty('VPNAddonManager', 'count'), 10) === - 1; + await vpn.getVPNProperty('VPNAddonManager', 'count'), 10) === + 1; }); }); @@ -63,68 +65,141 @@ describe('Addons', function() { await vpn.waitForCondition(async () => { return parseInt( - await vpn.getVPNProperty('VPNAddonManager', 'count'), 10) === - 1; + await vpn.getVPNProperty('VPNAddonManager', 'count'), 10) === + 1; }); const exitCityName = - await vpn.getVPNProperty('VPNCurrentServer', 'exitCityName'); + await vpn.getVPNProperty('VPNCurrentServer', 'exitCityName'); const exitCountryCode = - await vpn.getVPNProperty('VPNCurrentServer', 'exitCountryCode'); + await vpn.getVPNProperty('VPNCurrentServer', 'exitCountryCode'); // Let's start the tutorial await vpn.waitForQueryAndClick(queries.navBar.SETTINGS.visible()); await vpn.waitForQueryAndClick( - queries.screenSettings.TIPS_AND_TRICKS.visible()); + queries.screenSettings.TIPS_AND_TRICKS.visible()); await vpn.waitForQueryAndClick( - queries.screenSettings.TUTORIAL_LIST_HIGHLIGHT.visible()); + queries.screenSettings.TUTORIAL_LIST_HIGHLIGHT.visible()); // Confirmation dialog for settings-rollback await vpn.waitForQuery( - queries.screenHome.TUTORIAL_POPUP_PRIMARY_BUTTON.visible()); + queries.screenHome.TUTORIAL_POPUP_PRIMARY_BUTTON.visible()); assert( - (await vpn.getQueryProperty( - queries.screenHome.TUTORIAL_POPUP_PRIMARY_BUTTON.visible(), - 'text')) === 'Continue'); + (await vpn.getQueryProperty( + queries.screenHome.TUTORIAL_POPUP_PRIMARY_BUTTON.visible(), + 'text')) === 'Continue'); await vpn.clickOnQuery( - queries.screenHome.TUTORIAL_POPUP_PRIMARY_BUTTON.visible()); + queries.screenHome.TUTORIAL_POPUP_PRIMARY_BUTTON.visible()); await vpn.waitForCondition(async () => { return await vpn.getVPNProperty('VPNCurrentServer', 'exitCityName') === - 'Vienna'; + 'Vienna'; }); assert( - await vpn.getVPNProperty('VPNCurrentServer', 'exitCityName') === - 'Vienna'); + await vpn.getVPNProperty('VPNCurrentServer', 'exitCityName') === + 'Vienna'); assert( - await vpn.getVPNProperty('VPNCurrentServer', 'exitCountryCode') === - 'at'); + await vpn.getVPNProperty('VPNCurrentServer', 'exitCountryCode') === + 'at'); await vpn.waitForQuery(queries.screenHome.TUTORIAL_LEAVE.visible()); await vpn.waitForQueryAndClick( - queries.screenHome.SERVER_LIST_BUTTON.visible()); + queries.screenHome.SERVER_LIST_BUTTON.visible()); // Final dialog await vpn.waitForQuery( - queries.screenHome.TUTORIAL_POPUP_PRIMARY_BUTTON.visible()); + queries.screenHome.TUTORIAL_POPUP_PRIMARY_BUTTON.visible()); assert( - (await vpn.getQueryProperty( - queries.screenHome.TUTORIAL_POPUP_PRIMARY_BUTTON.visible(), - 'text')) === 'Let’s go!'); + (await vpn.getQueryProperty( + queries.screenHome.TUTORIAL_POPUP_PRIMARY_BUTTON.visible(), + 'text')) === 'Let’s go!'); await vpn.clickOnQuery( - queries.screenHome.TUTORIAL_POPUP_PRIMARY_BUTTON.visible()); + queries.screenHome.TUTORIAL_POPUP_PRIMARY_BUTTON.visible()); await vpn.waitForCondition(async () => { return await vpn.getVPNProperty('VPNCurrentServer', 'exitCityName') === - exitCityName; + exitCityName; }); assert( - await vpn.getVPNProperty('VPNCurrentServer', 'exitCityName') === - exitCityName); + await vpn.getVPNProperty('VPNCurrentServer', 'exitCityName') === + exitCityName); assert( - await vpn.getVPNProperty('VPNCurrentServer', 'exitCountryCode') === - exitCountryCode); + await vpn.getVPNProperty('VPNCurrentServer', 'exitCountryCode') === + exitCountryCode); + }); + + describe('test message_subscription_expiring addon condition', async () => { + const testCases = [ + ...Array.from( + { length: 7 }, + // 1 to 7 days out from expiring. + (_, i) => [Date.now() + 1000 * 60 * 60 * 24 * (i + 1), true, `is ${i + 1} day(s) away`] + ), + // Seven days out + five minutes from expiring. + [Date.now() + 1000 * 60 * 60 * 24 * 7 + 1000 * 60 * 5, false, "is 7 days and five minutes away"], + // Eight days from expiring. + [Date.now() + 1000 * 60 * 60 * 24 * 8, false, "is 8 days away"], + // One month from expiring. + [Date.now() + 1000 * 60 * 60 * 24 * 30, false, "is one month away"], + // Literally, has just expired. + [Date.now(), false, "just happened"], + // Has been expired for a day. + [Date.now() - 1000 * 60 * 60 * 24, false, "was yesterday"], + // Has been expired for 30 days. + [Date.now() - 1000 * 60 * 60 * 24 * 30, false, "was last month"], + ]; + + const getNextTestCase = testCases[Symbol.iterator](); + function setNextSubscriptionExpiry(ctx) { + const mockDetails = { ...SubscriptionDetails }; + const nextTestCase = getNextTestCase.next().value; + + if (nextTestCase) { + const [expiresOn] = nextTestCase; + // We are faking a Stripe subscription, so this value is expected to be in seconds. + mockDetails.subscription.current_period_end = expiresOn / 1000; + + ctx.guardianSubscriptionDetailsCallback = () => { + ctx.guardianOverrideEndpoints.GETs['/api/v1/vpn/subscriptionDetails'].status = 200; + ctx.guardianOverrideEndpoints + .GETs['/api/v1/vpn/subscriptionDetails'] + .body = mockDetails; + }; + } + } + + // We call this once before all tests to set up the first test, + // we can't use beforeEach because that is executed after the guardian endpoints are overriden. + // + // We need to setup for the next test before it even starts for the overrides to apply. + setNextSubscriptionExpiry(this.ctx); + afterEach(() => setNextSubscriptionExpiry(this.ctx)); + + testCases.forEach(([_, shouldBeAvailable, testCase]) => { + it(`message display is correct when subscription expiration ${testCase}`, async () => { + // This tests cannot be run in WASM due to Qt limitations. + // See: https://mozilla-hub.atlassian.net/browse/VPN-4127 + if (this.ctx.wasm) { + return; + } + + // Load all production addons. + // These are loaded all together, so we don't know the exact number of addons. + await vpn.resetAddons('prod'); + await vpn.waitForCondition(async () => ( + parseInt(await vpn.getVPNProperty('VPNAddonManager', 'count'), 10) > 0 + )); + + + // Check if the message is there or not. + await vpn.waitForCondition(async () => { + const loadedMessages = await vpn.messages(); + const isSubscriptionExpiringMessageAvailable = loadedMessages.includes("message_subscription_expiring"); + return shouldBeAvailable ? isSubscriptionExpiringMessageAvailable : !isSubscriptionExpiringMessageAvailable; + }); + }); + }); }); });