From 4ae38cae26f09bdfe4a97f7b47a4ee8786f16870 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 5 Oct 2025 11:03:04 +0530 Subject: [PATCH 1/6] chore: get config of school ai control with getAIControlStatus --- src/nls/root/strings.js | 8 +- src/services/EntitlementsManager.js | 2 + src/services/ai-control.js | 134 ++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 src/services/ai-control.js diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 2862b95152..9b662d6d73 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1715,5 +1715,11 @@ define({ "LICENSE_ACTIVATE_FAIL": "Failed to activate license", "LICENSE_ACTIVATE_FAIL_APPLY": "'Failed to apply license to device'", "LICENSE_ENTER_KEY": "Please enter a license key", - "LICENSE_REAPPLY_TO_DEVICE": "Already activated? Reapply system-wide" + "LICENSE_REAPPLY_TO_DEVICE": "Already activated? Reapply system-wide", + // AI CONTROL + "AI_CONTROL_ALL_ALLOWED_NO_CONFIG": "No AI config file found in system. AI is enabled for all users.", + "AI_CONTROL_ALL_ALLOWED": "AI is enabled for all users.", + "AI_CONTROL_USER_ALLOWED": "AI is enabled for user ({0}) but disabled for others", + "AI_CONTROL_ADMIN_DISABLED": "AI access has been disabled by your system administrator", + "AI_CONTROL_ADMIN_DISABLED_CONTACT": "AI access has been disabled by your system administrator. Please contact {0} for assistance." }); diff --git a/src/services/EntitlementsManager.js b/src/services/EntitlementsManager.js index 45781afa1f..5132f4c122 100644 --- a/src/services/EntitlementsManager.js +++ b/src/services/EntitlementsManager.js @@ -31,6 +31,7 @@ define(function (require, exports, module) { } const EventDispatcher = require("utils/EventDispatcher"), + AIControl = require("./ai-control"), Strings = require("strings"); const MS_IN_DAY = 24 * 60 * 60 * 1000; @@ -180,6 +181,7 @@ define(function (require, exports, module) { effectiveEntitlements = null; EntitlementsManager.trigger(EVENT_ENTITLEMENTS_CHANGED); }); + AIControl.init(); } // Test-only exports for integration testing diff --git a/src/services/ai-control.js b/src/services/ai-control.js new file mode 100644 index 0000000000..8bd205973d --- /dev/null +++ b/src/services/ai-control.js @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2021 - present core.ai. All rights reserved. + +/** + * This file is only relevant to desktop apps. + * + * AI can be not active in phoenix code either by: + * 1. Schools with admin control. See https://docs.phcode.dev/docs/control-ai + * 2. user is not entitled to ai services by his subscription. + * + * This file only deals with case 1. you should use `EntitlementsManager.js` to resolve the correct AI entitlment, + * which will reconcile 1 and 2 to give you appropriate status. + **/ + +/*global */ + +define(function (require, exports, module) { + const KernalModeTrust = window.KernalModeTrust; + if(!KernalModeTrust){ + // integrated extensions will have access to kernal mode, but not external extensions + throw new Error("ai-control.js should have access to KernalModeTrust. Cannot boot without trust ring"); + } + + const NodeUtils = require("utils/NodeUtils"), + Strings = require("strings"), + StringUtils = require("utils/StringUtils"); + let EntitlementsManager; + + /** + * Get the platform-specific config file path + * @returns {string} The path to the config file + */ + function _getAIConfigFilePath() { + let aiConfigPath; + // The path is decided by https://github.com/phcode-dev/phoenix-code-ai-control/tree/main/install_scripts + + if(!Phoenix.isNativeApp) { + return ""; + } + + if (Phoenix.platform === 'win') { + aiConfigPath = 'C:\\Program Files\\Phoenix AI Control\\config.json'; + } else if (Phoenix.platform === 'mac') { + aiConfigPath = '/Library/Application Support/Phoenix AI Control/config.json'; + } else if (Phoenix.platform === 'linux') { + aiConfigPath = '/etc/phoenix-ai-control/config.json'; + } else { + throw new Error(`Unsupported platform: ${Phoenix.platform}`); + } + return Phoenix.VFS.getTauriVirtualPath(aiConfigPath); + } + const AI_CONFIG_FILE_PATH = _getAIConfigFilePath(); + if(Phoenix.isNativeApp) { + console.log("AI system Config File is: ", AI_CONFIG_FILE_PATH); + } + + /** + * Check if the current user is in the allowed users list + * @param {Array} allowedUsers - List of allowed usernames + * @param {string} currentUser to check against + * @returns {boolean} True if current user is allowed + */ + function _isCurrentUserAllowed(allowedUsers, currentUser) { + if (!allowedUsers || !Array.isArray(allowedUsers) || allowedUsers.length === 0) { + return false; + } + + return allowedUsers.includes(currentUser); + } + + /** + * Get AI control configuration + * @returns {Object} The configuration status and details + */ + async function getAIControlStatus() { + try { + if(!Phoenix.isNativeApp) { + return {aiEnabled: true}; // AI control with system files in not available in browser. + // In browser, AI can be disabled with firewall only. + } + const fileData = await Phoenix.VFS.readFileResolves(AI_CONFIG_FILE_PATH, 'utf8'); + + if (fileData.error || !fileData.data) { + return { + aiEnabled: true, + message: Strings.AI_CONTROL_ALL_ALLOWED_NO_CONFIG + }; // No ai config file exists + } + + const aiConfig = JSON.parse(fileData.data); + const currentUser = await NodeUtils.getOSUserName(); + + // Check if AI is disabled globally + if (aiConfig.disableAI === true) { + // Check if current user is in allowed users list + if (aiConfig.allowedUsers && _isCurrentUserAllowed(aiConfig.allowedUsers, currentUser)) { + return { + aiEnabled: true, + message: StringUtils.format(Strings.AI_CONTROL_USER_ALLOWED, currentUser) + }; + } else if(aiConfig.managedByEmail){ + return { + aiEnabled: false, + message: StringUtils.format(Strings.AI_CONTROL_ADMIN_DISABLED_CONTACT, aiConfig.managedByEmail) + }; + } + return { + aiEnabled: false, + message: Strings.AI_CONTROL_ADMIN_DISABLED + }; + } + // AI is enabled globally + return { + aiEnabled: true, + message: Strings.AI_CONTROL_ALL_ALLOWED + }; + } catch (error) { + console.error('Error checking AI control:', error); + return {aiEnabled: true, message: error.message}; + } + } + + let inited = false; + function init() { + if(inited){ + return; + } + inited = true; + EntitlementsManager = KernalModeTrust.EntitlementsManager; + EntitlementsManager.getAIControlStatus = getAIControlStatus; + } + + exports.init = init; +}); From 7cf6e7b1684fed21a9bf670afee145f848a814df Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 5 Oct 2025 14:11:40 +0530 Subject: [PATCH 2/6] fix: entitlments cache misses for valid null entitlement --- src/services/EntitlementsManager.js | 10 +++++----- src/services/login-service.js | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/services/EntitlementsManager.js b/src/services/EntitlementsManager.js index 5132f4c122..b2760fa641 100644 --- a/src/services/EntitlementsManager.js +++ b/src/services/EntitlementsManager.js @@ -73,13 +73,13 @@ define(function (require, exports, module) { }); } - let effectiveEntitlements = null; + let effectiveEntitlementsCached = undefined; // entitlements can be null and its valid if no login/trial async function _getEffectiveEntitlements() { - if(effectiveEntitlements){ - return effectiveEntitlements; + if(effectiveEntitlementsCached !== undefined){ + return effectiveEntitlementsCached; } const entitlements = await LoginService.getEffectiveEntitlements(); - effectiveEntitlements = entitlements; + effectiveEntitlementsCached = entitlements; return entitlements; } @@ -178,7 +178,7 @@ define(function (require, exports, module) { LoginService = KernalModeTrust.loginService; // Set up event forwarding from LoginService LoginService.on(LoginService.EVENT_ENTITLEMENTS_CHANGED, function() { - effectiveEntitlements = null; + effectiveEntitlementsCached = null; EntitlementsManager.trigger(EVENT_ENTITLEMENTS_CHANGED); }); AIControl.init(); diff --git a/src/services/login-service.js b/src/services/login-service.js index ca5e5c7ea7..99667ae089 100644 --- a/src/services/login-service.js +++ b/src/services/login-service.js @@ -72,7 +72,7 @@ define(function (require, exports, module) { const EVENT_ENTITLEMENTS_CHANGED = "entitlements_changed"; // Cached entitlements data - let cachedEntitlements = null; + let cachedEntitlements = undefined; // Last recorded state for entitlements monitoring let lastRecordedState = null; @@ -266,7 +266,7 @@ define(function (require, exports, module) { } // Return cached data if available and not forcing refresh - if (cachedEntitlements && !forceRefresh) { + if (cachedEntitlements !== undefined && !forceRefresh) { return cachedEntitlements; } @@ -388,7 +388,7 @@ define(function (require, exports, module) { */ async function clearEntitlements() { if (cachedEntitlements) { - cachedEntitlements = null; + cachedEntitlements = undefined; _debounceEntitlementsChanged(); } // Reset device license state so it's re-evaluated on next entitlement check From cdbce01b9e4042910521ce229ed5e566ebbbcf60 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 5 Oct 2025 17:00:08 +0530 Subject: [PATCH 3/6] chore: getAIEntitlement api and showAIUpsellDialog --- src/config.json | 1 + src/nls/root/strings.js | 8 +- src/services/EntitlementsManager.js | 115 +++++++++++++++++++++++++++- src/services/login-service.js | 15 +++- src/services/pro-dialogs.js | 50 ++++++++++++ 5 files changed, 184 insertions(+), 5 deletions(-) diff --git a/src/config.json b/src/config.json index c60bc4f6e6..81ab869376 100644 --- a/src/config.json +++ b/src/config.json @@ -3,6 +3,7 @@ "app_title": "Phoenix Code", "app_name_about": "Phoenix Code", "main_pro_plan": "Phoenix Pro", + "ai_brand_name": "Phoenix AI", "about_icon": "styles/images/phoenix-icon.svg", "account_url": "https://account.phcode.dev/", "promotions_url": "https://promotions.phcode.dev/dev/", diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 9b662d6d73..33817ef248 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1717,9 +1717,15 @@ define({ "LICENSE_ENTER_KEY": "Please enter a license key", "LICENSE_REAPPLY_TO_DEVICE": "Already activated? Reapply system-wide", // AI CONTROL + "AI_LOGIN_DIALOG_TITLE": "Sign In to Use AI Edits", + "AI_LOGIN_DIALOG_MESSAGE": "Please log in to use AI-powered edits", + "AI_LOGIN_DIALOG_BUTTON": "Get AI Access", + "AI_DISABLED_DIALOG_TITLE": "AI is disabled", "AI_CONTROL_ALL_ALLOWED_NO_CONFIG": "No AI config file found in system. AI is enabled for all users.", "AI_CONTROL_ALL_ALLOWED": "AI is enabled for all users.", "AI_CONTROL_USER_ALLOWED": "AI is enabled for user ({0}) but disabled for others", "AI_CONTROL_ADMIN_DISABLED": "AI access has been disabled by your system administrator", - "AI_CONTROL_ADMIN_DISABLED_CONTACT": "AI access has been disabled by your system administrator. Please contact {0} for assistance." + "AI_CONTROL_ADMIN_DISABLED_CONTACT": "AI access has been disabled by your system administrator. Please contact {0} for assistance.", + "AI_UPSELL_DIALOG_TITLE": "Continue with {0}?", + "AI_UPSELL_DIALOG_MESSAGE": "You’ve discovered AI-powered edits. To proceed, you’ll need an AI subscription or credits." }); diff --git a/src/services/EntitlementsManager.js b/src/services/EntitlementsManager.js index b2760fa641..d2ffb3aa88 100644 --- a/src/services/EntitlementsManager.js +++ b/src/services/EntitlementsManager.js @@ -32,7 +32,8 @@ define(function (require, exports, module) { const EventDispatcher = require("utils/EventDispatcher"), AIControl = require("./ai-control"), - Strings = require("strings"); + Strings = require("strings"), + StringUtils = require("utils/StringUtils"); const MS_IN_DAY = 24 * 60 * 60 * 1000; const FREE_PLAN_VALIDITY_DAYS = 10000; @@ -169,6 +170,117 @@ define(function (require, exports, module) { }; } + /** + * Get AI is enabled for user, based on his logged in pro-user/trial status. + * + * @returns {Promise} AI entitlement object with the following shape: + * @returns {Promise} entitlement.activated - If true, enable AI features. If false, check upsellDialog. + * @returns {Promise} [entitlement.needsLogin] - If true, user needs to login first. + * @returns {Promise} [entitlement.aiBrandName] - The brand name used for AI. Eg: `Phoenix AI` + * @returns {Promise} [entitlement.buyURL] - URL to subscribe/purchase if not activated. Can be null if AI + * is not purchasable. + * @returns {Promise} [entitlement.upgradeToPlan] - Plan name that includes AI entitlement + * @returns {Promise} [entitlement.validTill] - Timestamp when entitlement expires (if from server) + * @returns {Promise} [entitlement.upsellDialog] - Dialog configuration if user needs to be shown an upsell. + * Only present when activated is false. + * @returns {Promise} [entitlement.upsellDialog.title] - Dialog title + * @returns {Promise} [entitlement.upsellDialog.message] - Dialog message + * @returns {Promise} [entitlement.upsellDialog.buyURL] - Purchase URL. If present, dialog shows + * "Get AI Access" button. If absent, shows only OK button. + * + * @example + * const aiEntitlement = await EntitlementsManager.getAIEntitlement(); + * if (aiEntitlement.activated) { + * // Enable AI features + * enableAIFeature(); + * } else if (aiEntitlement.upsellDialog) { + * // Show upsell dialog when user tries to use AI + * promotions.showAIUpsellDialog(aiEntitlement); + * } + */ + async function getAIEntitlement() { + if(!isLoggedIn()) { + return { + needsLogin: true, + activated: false, + upsellDialog: { + title: Strings.AI_LOGIN_DIALOG_TITLE, + message: Strings.AI_LOGIN_DIALOG_MESSAGE + // no buy url as it is a sign in hint. only ok button will be there in this dialog. + } + }; + } + const aiControlStatus = await EntitlementsManager.getAIControlStatus(); + if(!aiControlStatus.aiEnabled) { + return { + activated: false, + upsellDialog: { + title: Strings.AI_DISABLED_DIALOG_TITLE, + // Eg. AI is disabled by school admin/root user. + // no buyURL as ai is disabled explicitly. only ok button will be there in this dialog. + message: aiControlStatus.message || Strings.AI_CONTROL_ADMIN_DISABLED + } + }; + } + const defaultAIBrandName = brackets.config.ai_brand_name, + defaultPurchaseURL = brackets.config.purchase_url, + defaultUpsellTitle = StringUtils.format(Strings.AI_UPSELL_DIALOG_TITLE, defaultAIBrandName); + const entitlements = await _getEffectiveEntitlements(); + if(!entitlements || !entitlements.entitlements || !entitlements.entitlements.aiAgent) { + return { + activated: false, + aiBrandName: defaultAIBrandName, + buyURL: defaultPurchaseURL, + upgradeToPlan: defaultAIBrandName, + upsellDialog: { + title: defaultUpsellTitle, + message: Strings.AI_UPSELL_DIALOG_MESSAGE, + buyURL: defaultPurchaseURL + } + }; + } + const aiEntitlement = entitlements.entitlements.aiAgent; + // entitlements.entitlements.aiAgent: { + // activated: boolean, + // aiBrandName: string, + // subscribeURL: string, + // upgradeToPlan: string, + // validTill: number, + // upsellDialog: { + // title: "if activated is false, server can send a custom upsell dialog to show", + // message: "this is the message to show", + // buyURL: "if this url is present from server, this will be shown to as buy link" + // } + // } + + if(aiEntitlement.activated) { + return { + activated: true, + aiBrandName: aiEntitlement.aiBrandName, + buyURL: aiEntitlement.subscribeURL, + upgradeToPlan: aiEntitlement.upgradeToPlan, + validTill: aiEntitlement.validTill + // no upsellDialog, as it need not be shown. + }; + } + + const upsellTitle = StringUtils.format(Strings.AI_UPSELL_DIALOG_TITLE, + aiEntitlement.aiBrandName || defaultAIBrandName); + const upsellDialog = aiEntitlement.upsellDialog || {}; + return { + activated: false, + aiBrandName: aiEntitlement.aiBrandName, + buyURL: aiEntitlement.subscribeURL || defaultPurchaseURL, + upgradeToPlan: aiEntitlement.upgradeToPlan, + validTill: aiEntitlement.validTill, + upsellDialog: { + title: upsellDialog.title || upsellTitle, + message: upsellDialog.message || Strings.AI_UPSELL_DIALOG_MESSAGE, + buyURL: upsellDialog.buyURL || aiEntitlement.subscribeURL || defaultPurchaseURL + } + }; + } + let inited = false; function init() { if(inited){ @@ -209,5 +321,6 @@ define(function (require, exports, module) { EntitlementsManager.getTrialRemainingDays = getTrialRemainingDays; EntitlementsManager.getRawEntitlements = getRawEntitlements; EntitlementsManager.getLiveEditEntitlement = getLiveEditEntitlement; + EntitlementsManager.getAIEntitlement = getAIEntitlement; EntitlementsManager.EVENT_ENTITLEMENTS_CHANGED = EVENT_ENTITLEMENTS_CHANGED; }); diff --git a/src/services/login-service.js b/src/services/login-service.js index 99667ae089..63da795327 100644 --- a/src/services/login-service.js +++ b/src/services/login-service.js @@ -547,12 +547,17 @@ define(function (require, exports, module) { * upgradeToPlan: string, // Plan name that includes this entitlement * validTill: number // Timestamp when entitlement expires * }, - * liveEditAI: { + * aiAgent: { * activated: boolean, + * aiBrandName: string, * subscribeURL: string, - * purchaseCreditsURL: string, // URL to purchase AI credits * upgradeToPlan: string, - * validTill: number + * validTill: number, + * upsellDialog: { + * title: "if activated is false, server can send a custom upsell dialog to show", + * message: "this is the message to show", + * buyURL: "if this url is present from server, this will be shown to as buy link" + * } * } * } * } @@ -612,6 +617,8 @@ define(function (require, exports, module) { trialDaysRemaining: trialDaysRemaining, entitlements: { ...serverEntitlements.entitlements, + // below we only override things we grant in trial. AI which is not part of trial + // is always server injected. the EntitlementsManager will resolve it appropriately. liveEdit: { activated: true, subscribeURL: brackets.config.purchase_url, @@ -633,6 +640,8 @@ define(function (require, exports, module) { isInProTrial: true, trialDaysRemaining: trialDaysRemaining, entitlements: { + // below we only override things we grant in trial. AI which is not part of trial + // is always server injected. the EntitlementsManager will resolve it appropriately. liveEdit: { activated: true, subscribeURL: brackets.config.purchase_url, diff --git a/src/services/pro-dialogs.js b/src/services/pro-dialogs.js index 67d139aadf..a6224f14d8 100644 --- a/src/services/pro-dialogs.js +++ b/src/services/pro-dialogs.js @@ -26,6 +26,12 @@ */ define(function (require, exports, module) { + const KernalModeTrust = window.KernalModeTrust; + if(!KernalModeTrust){ + // integrated extensions will have access to kernal mode, but not external extensions + throw new Error("pro-dialogs.js should have access to KernalModeTrust. Cannot boot without trust ring"); + } + const proTitle = ` ${brackets.config.main_pro_plan} @@ -165,6 +171,49 @@ define(function (require, exports, module) { } } + function showAIUpsellDialog(getAIEntitlementResponse) { + // Only show dialog if upsellDialog field is present + if (!getAIEntitlementResponse || !getAIEntitlementResponse.upsellDialog) { + return; + } + + const upsellDialog = getAIEntitlementResponse.upsellDialog; + const title = upsellDialog.title; + const message = upsellDialog.message; + const buyURL = upsellDialog.buyURL; + const needsLogin = getAIEntitlementResponse.needsLogin; + + let buttons; + if (needsLogin || buyURL) { + // Show primary action button and Cancel + const primaryButtonText = needsLogin ? Strings.PROFILE_SIGN_IN : Strings.AI_LOGIN_DIALOG_BUTTON; + buttons = [ + { className: Dialogs.DIALOG_BTN_CLASS_NORMAL, id: Dialogs.DIALOG_BTN_CANCEL, text: Strings.CANCEL }, + { className: Dialogs.DIALOG_BTN_CLASS_PRIMARY, id: "mainAction", text: primaryButtonText } + ]; + } else { + // Show only OK button (for disabled AI messages) + buttons = [ + { className: Dialogs.DIALOG_BTN_CLASS_PRIMARY, id: Dialogs.DIALOG_BTN_OK, text: Strings.OK } + ]; + } + + Dialogs.showModalDialog(Dialogs.DIALOG_ID_INFO, title, message, buttons).done(function (id) { + Metrics.countEvent(Metrics.EVENT_TYPE.AI, "dlgUpsell", "show"); + if(id === 'mainAction') { + if (needsLogin) { + Metrics.countEvent(Metrics.EVENT_TYPE.AI, "dlgUpsell", "signIn"); + KernalModeTrust.EntitlementsManager.loginToAccount(); + } else { + Metrics.countEvent(Metrics.EVENT_TYPE.AI, "dlgUpsell", "buyClick"); + Phoenix.app.openURLInDefaultBrowser(buyURL); + } + } else { + Metrics.countEvent(Metrics.EVENT_TYPE.AI, "dlgUpsell", id); + } + }); + } + if (Phoenix.isTestWindow) { window._test_pro_dlg_login_exports = { setFetchFn: function _setDdateNowFn(fn) { @@ -175,6 +224,7 @@ define(function (require, exports, module) { exports.showProTrialStartDialog = showProTrialStartDialog; exports.showProUpsellDialog = showProUpsellDialog; + exports.showAIUpsellDialog = showAIUpsellDialog; exports.UPSELL_TYPE_PRO_TRIAL_ENDED = UPSELL_TYPE_PRO_TRIAL_ENDED; exports.UPSELL_TYPE_GET_PRO = UPSELL_TYPE_GET_PRO; exports.UPSELL_TYPE_LIVE_EDIT = UPSELL_TYPE_LIVE_EDIT; From a3ff4d16bee428e2895c4fc0c12c62551a74ecfe Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 5 Oct 2025 19:17:30 +0530 Subject: [PATCH 4/6] fix: login integ tests fails as entitlments cache not properly cleaned up --- src/services/EntitlementsManager.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/EntitlementsManager.js b/src/services/EntitlementsManager.js index d2ffb3aa88..6f5638642e 100644 --- a/src/services/EntitlementsManager.js +++ b/src/services/EntitlementsManager.js @@ -290,7 +290,7 @@ define(function (require, exports, module) { LoginService = KernalModeTrust.loginService; // Set up event forwarding from LoginService LoginService.on(LoginService.EVENT_ENTITLEMENTS_CHANGED, function() { - effectiveEntitlementsCached = null; + effectiveEntitlementsCached = undefined; EntitlementsManager.trigger(EVENT_ENTITLEMENTS_CHANGED); }); AIControl.init(); From c9bcf5a8f3c63307a2ec93c35c193ffcbdf372a0 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 5 Oct 2025 19:48:09 +0530 Subject: [PATCH 5/6] fix: login integ tests fails as entitlments changed debounce race --- test/spec/login-shared.js | 50 ++++++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/test/spec/login-shared.js b/test/spec/login-shared.js index a97dbfeabc..ae67d8f077 100644 --- a/test/spec/login-shared.js +++ b/test/spec/login-shared.js @@ -170,22 +170,60 @@ define(function (require, exports, module) { } // Entitlements test utility functions + // Note: EntitlementsExports.getPlanDetails() is eventually consistent due to + // 1 second debounce delay for entitlements changed event async function verifyPlanEntitlements(expectedPlan, _testDescription) { - const planDetails = await EntitlementsExports.getPlanDetails(); + // Wait for plan details to match expected values (handles debounce delay) + let planDetails; + await awaitsFor( + async function () { + planDetails = await EntitlementsExports.getPlanDetails(); + + if (!expectedPlan) { + return planDetails !== undefined; // Should always return something (fallback) + } + + if (!planDetails) { + return false; + } + + // Check all expected properties match + if (expectedPlan.paidSubscriber !== undefined && + planDetails.paidSubscriber !== expectedPlan.paidSubscriber) { + return false; + } + if (expectedPlan.name && planDetails.name !== expectedPlan.name) { + return false; + } + if (expectedPlan.validTill !== undefined && !planDetails.validTill) { + return false; + } + + return true; + }, + ()=>{ + return `Plan entitlements ${JSON.stringify(planDetails)} to match expected ${ + JSON.stringify(expectedPlan)}: ${_testDescription}`; + }, + 4000, + 30 + ); + // Final assertions after condition is met + const finalPlanDetails = await EntitlementsExports.getPlanDetails(); if (expectedPlan) { - expect(planDetails).toBeDefined(); + expect(finalPlanDetails).toBeDefined(); if (expectedPlan.paidSubscriber !== undefined) { - expect(planDetails.paidSubscriber).toBe(expectedPlan.paidSubscriber); + expect(finalPlanDetails.paidSubscriber).toBe(expectedPlan.paidSubscriber); } if (expectedPlan.name) { - expect(planDetails.name).toBe(expectedPlan.name); + expect(finalPlanDetails.name).toBe(expectedPlan.name); } if (expectedPlan.validTill !== undefined) { - expect(planDetails.validTill).toBeDefined(); + expect(finalPlanDetails.validTill).toBeDefined(); } } else { - expect(planDetails).toBeDefined(); // Should always return something (fallback) + expect(finalPlanDetails).toBeDefined(); // Should always return something (fallback) } } From 3da55f8ab68127a34e86bbfe3db5646182fa8c34 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 5 Oct 2025 20:21:35 +0530 Subject: [PATCH 6/6] fix: never allow integ tests to hit actual login endpoint as races happen --- src/services/login-browser.js | 8 ++++++++ src/services/login-desktop.js | 12 ++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/services/login-browser.js b/src/services/login-browser.js index d298484e56..284f1495e9 100644 --- a/src/services/login-browser.js +++ b/src/services/login-browser.js @@ -113,6 +113,10 @@ define(function (require, exports, module) { return {err: ERR_RETRY_LATER}; } try { + if(Phoenix.isTestWindow && fetchFn === fetch){ + // so we never allow tests to hit the actual login service. + return {err: ERR_NOT_LOGGED_IN}; + } const response = await fetchFn(resolveURL, { method: 'GET', credentials: 'include', // Include cookies @@ -333,6 +337,10 @@ define(function (require, exports, module) { async function signOutBrowser() { const logoutURL = `${_getAccountBaseURL()}/signOut`; try { + if(Phoenix.isTestWindow && fetchFn === fetch){ + // so we never allow tests to hit the actual login service. + return; + } const response = await fetchFn(logoutURL, { method: 'POST', credentials: 'include', // Include cookies diff --git a/src/services/login-desktop.js b/src/services/login-desktop.js index 82d6286688..e7058654e6 100644 --- a/src/services/login-desktop.js +++ b/src/services/login-desktop.js @@ -101,6 +101,10 @@ define(function (require, exports, module) { return {err: ERR_RETRY_LATER}; } try { + if(Phoenix.isTestWindow && fetchFn === fetch){ + // so we never allow tests to hit the actual login service. + return {err: ERR_INVALID}; + } const response = await fetchFn(resolveURL); if (response.status === 400 || response.status === 404) { // 404 api key not found and 400 Bad Request, eg: verification code mismatch @@ -195,6 +199,10 @@ define(function (require, exports, module) { const resolveURL = `${Phoenix.config.account_url}getAppAuthSession?autoAuthPort=${authPortURL}&appName=${appName}`; // {"isSuccess":true,"appSessionID":"a uuid...","validationCode":"SWXP07"} try { + if(Phoenix.isTestWindow && fetchFn === fetch){ + // so we never allow tests to hit the actual login service. + return null; + } const response = await fetchFn(resolveURL); if (response.ok) { const {appSessionID, validationCode} = await response.json(); @@ -348,6 +356,10 @@ define(function (require, exports, module) { appSessionID: userProfile.apiKey }; + if(Phoenix.isTestWindow && fetchFn === fetch){ + // so we never allow tests to hit the actual login service. + return; + } const response = await fetchFn(resolveURL, { method: 'POST', headers: {