diff --git a/assets/images/background/bannerE.svg b/assets/images/background/bannerE.svg
index 4904357b7cd94..dd0cda10e8846 100644
--- a/assets/images/background/bannerE.svg
+++ b/assets/images/background/bannerE.svg
@@ -1,929 +1 @@
-
+
\ No newline at end of file
diff --git a/core/domain/auth_services_test.py b/core/domain/auth_services_test.py
index c7086add397f8..23d8e19d6f57a 100644
--- a/core/domain/auth_services_test.py
+++ b/core/domain/auth_services_test.py
@@ -53,9 +53,9 @@ def setUp(self) -> None:
'full_user_1',
'12345',
[constants.DEFAULT_LANGUAGE_CODE],
- None,
- None,
- None,
+ 'en',
+ 'en',
+ 'en',
user_id=self.full_user_id,
)
self.modifiable_profile_user_data = [
@@ -63,17 +63,17 @@ def setUp(self) -> None:
'profile_user_1',
'12345',
[constants.DEFAULT_LANGUAGE_CODE],
- None,
- None,
- None,
+ 'en',
+ 'en',
+ 'en',
),
user_domain.ModifiableUserData(
'profile_user_2',
'12345',
[constants.DEFAULT_LANGUAGE_CODE],
- None,
- None,
- None,
+ 'en',
+ 'en',
+ 'en',
),
]
diff --git a/core/domain/user_domain.py b/core/domain/user_domain.py
index 7f816cfab5ea9..74b52844a65b4 100644
--- a/core/domain/user_domain.py
+++ b/core/domain/user_domain.py
@@ -1408,9 +1408,9 @@ class ModifiableUserDataDict(TypedDict):
display_alias: str
pin: Optional[str]
preferred_language_codes: List[str]
- preferred_site_language_code: Optional[str]
- preferred_audio_language_code: Optional[str]
- preferred_translation_language_code: Optional[str]
+ preferred_site_language_code: str
+ preferred_audio_language_code: str
+ preferred_translation_language_code: str
user_id: Optional[str]
@@ -1421,9 +1421,9 @@ class RawUserDataDict(TypedDict):
display_alias: str
pin: Optional[str]
preferred_language_codes: List[str]
- preferred_site_language_code: Optional[str]
- preferred_audio_language_code: Optional[str]
- preferred_translation_language_code: Optional[str]
+ preferred_site_language_code: str
+ preferred_audio_language_code: str
+ preferred_translation_language_code: str
user_id: Optional[str]
@@ -1437,11 +1437,12 @@ def __init__(
display_alias: str,
pin: Optional[str],
preferred_language_codes: List[str],
- preferred_site_language_code: Optional[str],
- preferred_audio_language_code: Optional[str],
- preferred_translation_language_code: Optional[str],
+ preferred_site_language_code: str,
+ preferred_audio_language_code: str,
+ preferred_translation_language_code: str,
user_id: Optional[str] = None,
) -> None:
+ # Atribuições permanecem idênticas...
"""Constructs a ModifiableUserData domain object.
Args:
diff --git a/core/domain/user_domain_test.py b/core/domain/user_domain_test.py
index faf67eae51386..820a42212c466 100644
--- a/core/domain/user_domain_test.py
+++ b/core/domain/user_domain_test.py
@@ -64,9 +64,9 @@ def __init__(
display_alias: str,
pin: Optional[str],
preferred_language_codes: List[str],
- preferred_site_language_code: Optional[str],
- preferred_audio_language_code: Optional[str],
- preferred_translation_language_code: Optional[str],
+ preferred_site_language_code: str,
+ preferred_audio_language_code: str,
+ preferred_translation_language_code: str,
user_id: Optional[str] = None,
fake_field: Optional[str] = None,
) -> None:
@@ -94,9 +94,10 @@ def from_dict( # type: ignore[override]
modifiable_user_data_dict['display_alias'],
modifiable_user_data_dict['pin'],
modifiable_user_data_dict['preferred_language_codes'],
- modifiable_user_data_dict['preferred_site_language_code'],
- modifiable_user_data_dict['preferred_audio_language_code'],
- modifiable_user_data_dict['preferred_translation_language_code'],
+ modifiable_user_data_dict['preferred_site_language_code'] or '',
+ modifiable_user_data_dict['preferred_audio_language_code'] or '',
+ modifiable_user_data_dict['preferred_translation_language_code']
+ or '',
modifiable_user_data_dict['user_id'],
modifiable_user_data_dict['fake_field'],
)
@@ -146,9 +147,9 @@ def setUp(self) -> None:
'display_alias': 'display_alias',
'pin': '12345',
'preferred_language_codes': [constants.DEFAULT_LANGUAGE_CODE],
- 'preferred_site_language_code': None,
- 'preferred_audio_language_code': None,
- 'preferred_translation_language_code': None,
+ 'preferred_site_language_code': 'en',
+ 'preferred_audio_language_code': 'en',
+ 'preferred_translation_language_code': 'en',
'user_id': 'user_id',
}
self.modifiable_user_data = (
@@ -159,9 +160,9 @@ def setUp(self) -> None:
'display_alias': 'display_alias_3',
'pin': None,
'preferred_language_codes': [constants.DEFAULT_LANGUAGE_CODE],
- 'preferred_site_language_code': None,
- 'preferred_audio_language_code': None,
- 'preferred_translation_language_code': None,
+ 'preferred_site_language_code': 'en',
+ 'preferred_audio_language_code': 'en',
+ 'preferred_translation_language_code': 'en',
'user_id': None,
}
self.modifiable_new_user_data = (
diff --git a/core/domain/user_services_test.py b/core/domain/user_services_test.py
index f36e8f8e57013..1409a12b17b66 100644
--- a/core/domain/user_services_test.py
+++ b/core/domain/user_services_test.py
@@ -94,9 +94,9 @@ def setUp(self) -> None:
'display_alias': 'display_alias',
'pin': '12345',
'preferred_language_codes': [constants.DEFAULT_LANGUAGE_CODE],
- 'preferred_site_language_code': None,
- 'preferred_audio_language_code': None,
- 'preferred_translation_language_code': None,
+ 'preferred_site_language_code': 'en',
+ 'preferred_audio_language_code': 'en',
+ 'preferred_translation_language_code': 'en',
'user_id': 'user_id',
}
new_user_data_dict: user_domain.RawUserDataDict = {
@@ -104,9 +104,9 @@ def setUp(self) -> None:
'display_alias': 'display_alias3',
'pin': '12345',
'preferred_language_codes': [constants.DEFAULT_LANGUAGE_CODE],
- 'preferred_site_language_code': None,
- 'preferred_audio_language_code': None,
- 'preferred_translation_language_code': None,
+ 'preferred_site_language_code': 'en',
+ 'preferred_audio_language_code': 'en',
+ 'preferred_translation_language_code': 'en',
'user_id': None,
}
self.modifiable_user_data = (
@@ -1341,9 +1341,9 @@ def test_profile_user_settings_have_correct_roles(self) -> None:
'display_alias': 'display_alias3',
'pin': '12345',
'preferred_language_codes': [constants.DEFAULT_LANGUAGE_CODE],
- 'preferred_site_language_code': None,
- 'preferred_audio_language_code': None,
- 'preferred_translation_language_code': None,
+ 'preferred_site_language_code': 'en',
+ 'preferred_audio_language_code': 'en',
+ 'preferred_translation_language_code': 'en',
'user_id': None,
}
modifiable_user_data = user_domain.ModifiableUserData.from_raw_dict(
@@ -1714,9 +1714,9 @@ def test_create_multiple_new_profiles_for_same_user_works_correctly(
'display_alias': display_alias_3,
'pin': None,
'preferred_language_codes': [constants.DEFAULT_LANGUAGE_CODE],
- 'preferred_site_language_code': None,
- 'preferred_audio_language_code': None,
- 'preferred_translation_language_code': None,
+ 'preferred_site_language_code': 'en',
+ 'preferred_audio_language_code': 'en',
+ 'preferred_translation_language_code': 'en',
'user_id': None,
}
modifiable_new_user_data_2 = (
@@ -1880,9 +1880,9 @@ def test_update_users_data_for_multiple_users_works_correctly(self) -> None:
'display_alias': display_alias_3,
'pin': None,
'preferred_language_codes': [constants.DEFAULT_LANGUAGE_CODE],
- 'preferred_site_language_code': None,
- 'preferred_audio_language_code': None,
- 'preferred_translation_language_code': None,
+ 'preferred_site_language_code': 'en',
+ 'preferred_audio_language_code': 'en',
+ 'preferred_translation_language_code': 'en',
'user_id': None,
}
modifiable_new_user_data_2 = (
diff --git a/core/domain/wipeout_service_test.py b/core/domain/wipeout_service_test.py
index 624d8c280fb3f..90d224f3eda45 100644
--- a/core/domain/wipeout_service_test.py
+++ b/core/domain/wipeout_service_test.py
@@ -280,9 +280,9 @@ def setUp(self) -> None:
'display_alias': 'display_alias',
'pin': '12345',
'preferred_language_codes': [constants.DEFAULT_LANGUAGE_CODE],
- 'preferred_site_language_code': None,
- 'preferred_audio_language_code': None,
- 'preferred_translation_language_code': None,
+ 'preferred_site_language_code': 'en',
+ 'preferred_audio_language_code': 'en',
+ 'preferred_translation_language_code': 'en',
'user_id': self.user_1_id,
}
new_user_data_dict: user_domain.RawUserDataDict = {
@@ -290,9 +290,9 @@ def setUp(self) -> None:
'display_alias': 'display_alias3',
'pin': '12345',
'preferred_language_codes': [constants.DEFAULT_LANGUAGE_CODE],
- 'preferred_site_language_code': None,
- 'preferred_audio_language_code': None,
- 'preferred_translation_language_code': None,
+ 'preferred_site_language_code': 'en',
+ 'preferred_audio_language_code': 'en',
+ 'preferred_translation_language_code': 'en',
'user_id': None,
}
self.modifiable_user_data = (
@@ -5465,9 +5465,9 @@ def setUp(self) -> None:
'display_alias': 'display_alias',
'pin': '12345',
'preferred_language_codes': [constants.DEFAULT_LANGUAGE_CODE],
- 'preferred_site_language_code': None,
- 'preferred_audio_language_code': None,
- 'preferred_translation_language_code': None,
+ 'preferred_site_language_code': 'en',
+ 'preferred_audio_language_code': 'en',
+ 'preferred_translation_language_code': 'en',
'user_id': self.user_1_id,
}
new_user_data_dict: user_domain.RawUserDataDict = {
@@ -5475,9 +5475,9 @@ def setUp(self) -> None:
'display_alias': 'display_alias3',
'pin': '12345',
'preferred_language_codes': [constants.DEFAULT_LANGUAGE_CODE],
- 'preferred_site_language_code': None,
- 'preferred_audio_language_code': None,
- 'preferred_translation_language_code': None,
+ 'preferred_site_language_code': 'en',
+ 'preferred_audio_language_code': 'en',
+ 'preferred_translation_language_code': 'en',
'user_id': None,
}
self.modifiable_user_data = (
@@ -5864,9 +5864,9 @@ def setUp(self) -> None:
'display_alias': 'display_alias',
'pin': '12345',
'preferred_language_codes': [constants.DEFAULT_LANGUAGE_CODE],
- 'preferred_site_language_code': None,
- 'preferred_audio_language_code': None,
- 'preferred_translation_language_code': None,
+ 'preferred_site_language_code': 'en',
+ 'preferred_audio_language_code': 'en',
+ 'preferred_translation_language_code': 'en',
'user_id': self.user_1_id,
}
new_user_data_dict: user_domain.RawUserDataDict = {
@@ -5874,9 +5874,9 @@ def setUp(self) -> None:
'display_alias': 'display_alias3',
'pin': '12345',
'preferred_language_codes': [constants.DEFAULT_LANGUAGE_CODE],
- 'preferred_site_language_code': None,
- 'preferred_audio_language_code': None,
- 'preferred_translation_language_code': None,
+ 'preferred_site_language_code': 'en',
+ 'preferred_audio_language_code': 'en',
+ 'preferred_translation_language_code': 'en',
'user_id': None,
}
self.modifiable_user_data = (
diff --git a/core/templates/mathjaxConfig.ts b/core/templates/mathjaxConfig.ts
index a6dd3c5afd1c1..051f840ff9c7e 100644
--- a/core/templates/mathjaxConfig.ts
+++ b/core/templates/mathjaxConfig.ts
@@ -14,6 +14,10 @@ window.MathJax = {
showProcessingMessages: false,
SVG: {
useGlobalCache: false,
+ // This setting forces MathJax to inherit the page's font, which allows
+ // the browser's native Complex Text Layout (CTL) engine to properly connect
+ // right-to-left Arabic cursive characters inside \text{...} blocks (Fixes #26148).
+ mtextFontInherit: true,
linebreaks: {
automatic: true,
width: '500px',
diff --git a/core/tests/ci-test-suite-configs/acceptance.json b/core/tests/ci-test-suite-configs/acceptance.json
index e52b5b96500cb..dabcd22821ca2 100644
--- a/core/tests/ci-test-suite-configs/acceptance.json
+++ b/core/tests/ci-test-suite-configs/acceptance.json
@@ -462,8 +462,8 @@
},
{
"name": "logged-in-learner/reaches-a-checkpoint-and-saves-their-progress",
- "module": "core/tests/puppeteer-acceptance-tests/specs/logged-in-learner/reaches-a-checkpoint-and-saves-their-progress.spec.ts",
- "framework": "puppeteer"
+ "module": "core/tests/playwright-acceptance-tests/specs/logged-in-learner/reaches-a-checkpoint-and-saves-their-progress.spec.ts",
+ "framework": "playwright"
},
{
"name": "logged-in-learner/starts-from-beginning-after-completing-a-lesson",
@@ -487,8 +487,8 @@
},
{
"name": "logged-in-learner/changes-site-language-to-rtl",
- "module": "core/tests/puppeteer-acceptance-tests/specs/logged-in-learner/changes-site-language-to-rtl.spec.ts",
- "framework": "puppeteer"
+ "module": "core/tests/playwright-acceptance-tests/specs/logged-in-learner/changes-site-language-to-rtl.spec.ts",
+ "framework": "playwright"
},
{
"name": "logged-in-learner/edit-the-profile",
@@ -497,8 +497,8 @@
},
{
"name": "logged-in-learner/export-and-delete-account",
- "module": "core/tests/puppeteer-acceptance-tests/specs/logged-in-learner/export-and-delete-account.spec.ts",
- "framework": "puppeteer"
+ "module": "core/tests/playwright-acceptance-tests/specs/logged-in-learner/export-and-delete-account.spec.ts",
+ "framework": "playwright"
},
{
"name": "logged-in-learner/manage-goals-in-learner-dashboard",
diff --git a/core/tests/playwright-acceptance-tests/playwright.config.ts b/core/tests/playwright-acceptance-tests/playwright.config.ts
index 01c863d236073..c6d27829fc924 100644
--- a/core/tests/playwright-acceptance-tests/playwright.config.ts
+++ b/core/tests/playwright-acceptance-tests/playwright.config.ts
@@ -17,6 +17,12 @@
*/
import {defineConfig, devices} from '@playwright/test';
+import path from 'path';
+
+const PLAYWRIGHT_RESULTS_DIR = path.resolve(
+ __dirname,
+ '../../../../oppia_full_stack_test_playwright_results'
+);
const isMobile = process.env.MOBILE === 'true';
const isCI = process.env.PROD_ENV === 'true';
@@ -29,6 +35,7 @@ export default defineConfig({
},
timeout: 10000,
},
+ outputDir: PLAYWRIGHT_RESULTS_DIR,
testDir: './specs',
timeout: 300000,
fullyParallel: false,
diff --git a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/changes-site-language-to-rtl.spec.ts b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/changes-site-language-to-rtl.spec.ts
new file mode 100644
index 0000000000000..ef35e384c2055
--- /dev/null
+++ b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/changes-site-language-to-rtl.spec.ts
@@ -0,0 +1,176 @@
+// Copyright 2026 The Oppia Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS-IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/**
+ * @fileoverview Acceptance test from CUJv3 Doc
+ * https://docs.google.com/document/d/1D7kkFTzg3rxUe3QJ_iPlnxUzBFNElmRkmAWss00nFno/
+ *
+ * PP. Learner changes the site Language to an RTL (right-to-left) language
+ */
+
+import {test} from '@playwright/test';
+import testConstants from '../../utilities/common/test-constants';
+import {UserFactory} from '../../utilities/common/user-factory';
+import {CurriculumAdmin} from '../../utilities/user/curriculum-admin';
+import {ExplorationEditor} from '../../utilities/user/exploration-editor';
+import {LoggedInUser} from '../../utilities/user/logged-in-user';
+import {LoggedOutUser} from '../../utilities/user/logged-out-user';
+
+const ROLES = testConstants.Roles;
+
+test.describe.configure({mode: 'serial'});
+
+test.describe('Logged-In Learner', function () {
+ let loggedInUser1: LoggedInUser & LoggedOutUser;
+ let curriculumAdmin: CurriculumAdmin & ExplorationEditor;
+ let explorationId: string | null;
+
+ test.beforeAll(async function ({browser}) {
+ test.setTimeout(600000);
+ loggedInUser1 = await UserFactory.createNewUser(
+ 'loggedInLearner',
+ 'logged_in_learner@example.com',
+ browser
+ );
+
+ curriculumAdmin = await UserFactory.createNewUser(
+ 'curriculumAdm',
+ 'curriculumAdmin@example.com',
+ browser,
+ [ROLES.CURRICULUM_ADMIN]
+ );
+
+ await curriculumAdmin.navigateToCreatorDashboardPage();
+ await curriculumAdmin.navigateToExplorationEditorFromCreatorDashboard();
+ await curriculumAdmin.dismissWelcomeModal();
+ await curriculumAdmin.updateCardContent('Introduction to Fractions');
+ await curriculumAdmin.addInteraction('Continue Button');
+
+ // Add a new card with a basic algebra problem.
+ await curriculumAdmin.viewOppiaResponses();
+ await curriculumAdmin.directLearnersToNewCard('Second Card');
+ await curriculumAdmin.saveExplorationDraft();
+
+ // Navigate to the new card and update its content.
+ await curriculumAdmin.navigateToCard('Second Card');
+ await curriculumAdmin.updateCardContent('Enter a negative number.');
+ await curriculumAdmin.addInteraction('Number Input');
+
+ await curriculumAdmin.addResponsesToTheInteraction(
+ 'Number Input',
+ '-1',
+ 'Perfect!',
+ 'Last Card',
+ true
+ );
+ await curriculumAdmin.editDefaultResponseFeedbackInExplorationEditorPage(
+ 'Wrong, try again!'
+ );
+ await curriculumAdmin.addHintToState(
+ 'Remember that negative numbers are less than 0.'
+ );
+ await curriculumAdmin.addSolutionToState(
+ '-99',
+ 'The number -99 is a negative number.',
+ true
+ );
+ await curriculumAdmin.saveExplorationDraft();
+
+ // Navigate to the new card and add Study Guide content.
+ await curriculumAdmin.navigateToCard('Last Card');
+ await curriculumAdmin.updateCardContent(
+ 'Congratulations! You have completed the exploration.'
+ );
+ await curriculumAdmin.addInteraction('End Exploration');
+
+ // Save the draft.
+ await curriculumAdmin.saveExplorationDraft();
+ explorationId = await curriculumAdmin.publishExplorationWithMetadata(
+ 'What is a Fraction?',
+ 'Learn the basics of Fractions',
+ 'Algebra'
+ );
+
+ await curriculumAdmin.createAndPublishTopic(
+ 'Fractions',
+ 'Basics Of Fractions',
+ 'fractions'
+ );
+
+ await curriculumAdmin.createAndPublishClassroom(
+ 'Math',
+ 'math',
+ 'Fractions'
+ );
+
+ await curriculumAdmin.createAndPublishStoryWithChapter(
+ 'Fraction Story',
+ 'fraction-story',
+ 'What is a Fraction?',
+ explorationId as string,
+ 'Fractions'
+ );
+ });
+
+ test('should be able to change the site language to an RTL language', async function () {
+ await loggedInUser1.changeSiteLanguage('ar');
+
+ await loggedInUser1.expectElementToBeVisible('.mat-mdc-menu-panel', false);
+
+ await loggedInUser1.navigateToLearnerDashboard();
+
+ await loggedInUser1.verifyPageIsRTL();
+
+ await loggedInUser1.expectScreenshotToMatch('RTLArabicLearnerDashboard');
+
+ await loggedInUser1.navigateToHome(false);
+
+ await loggedInUser1.verifyPageIsRTL();
+
+ await loggedInUser1.expectScreenshotToMatch('RTLArabicHomePage');
+ });
+
+ test('should be able to visit about page', async function () {
+ // Navigate to about page.
+ await loggedInUser1.clickAboutButtonInAboutMenuOnNavbar();
+ await loggedInUser1.verifyPageIsRTL();
+ await loggedInUser1.expectScreenshotToMatch('RTLArabicAboutPage');
+ });
+
+ test('should be able to play an exploration and interact with pop-ups, modals and buttons', async function () {
+ // Navigate to community library.
+ await loggedInUser1.navigateToCommunityLibraryPage();
+ await loggedInUser1.verifyPageIsRTL();
+
+ // Check lesson player.
+ await loggedInUser1.searchForLessonInSearchBar('What is a Fraction?');
+ await loggedInUser1.playLessonFromSearchResults('What is a Fraction?');
+ await loggedInUser1.verifyPageIsRTL();
+
+ // Check hints and lesson info are displayed in RTL.
+ await loggedInUser1.continueToNextCard();
+ await loggedInUser1.submitAnswer('1');
+
+ await loggedInUser1.viewHint();
+ await loggedInUser1.verifyPageIsRTL();
+ await loggedInUser1.closeHintModal();
+
+ await loggedInUser1.openLessonInfoModal();
+ await loggedInUser1.verifyPageIsRTL();
+ });
+
+ test.afterAll(async function () {
+ await UserFactory.closeAllBrowsers();
+ });
+});
diff --git a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/dev-desktop-screenshots/RTLArabicAboutPage.png b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/dev-desktop-screenshots/RTLArabicAboutPage.png
new file mode 100644
index 0000000000000..58094b2db6ad5
Binary files /dev/null and b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/dev-desktop-screenshots/RTLArabicAboutPage.png differ
diff --git a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/dev-desktop-screenshots/RTLArabicHomePage.png b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/dev-desktop-screenshots/RTLArabicHomePage.png
new file mode 100644
index 0000000000000..744ed18090d1e
Binary files /dev/null and b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/dev-desktop-screenshots/RTLArabicHomePage.png differ
diff --git a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/dev-desktop-screenshots/RTLArabicLearnerDashboard.png b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/dev-desktop-screenshots/RTLArabicLearnerDashboard.png
new file mode 100644
index 0000000000000..0e10038623471
Binary files /dev/null and b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/dev-desktop-screenshots/RTLArabicLearnerDashboard.png differ
diff --git a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/dev-mobile-screenshots/RTLArabicAboutPage.png b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/dev-mobile-screenshots/RTLArabicAboutPage.png
new file mode 100644
index 0000000000000..390573325109f
Binary files /dev/null and b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/dev-mobile-screenshots/RTLArabicAboutPage.png differ
diff --git a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/dev-mobile-screenshots/RTLArabicHomePage.png b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/dev-mobile-screenshots/RTLArabicHomePage.png
new file mode 100644
index 0000000000000..4e80b1c3ac90a
Binary files /dev/null and b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/dev-mobile-screenshots/RTLArabicHomePage.png differ
diff --git a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/dev-mobile-screenshots/RTLArabicLearnerDashboard.png b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/dev-mobile-screenshots/RTLArabicLearnerDashboard.png
new file mode 100644
index 0000000000000..4258d911a2651
Binary files /dev/null and b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/dev-mobile-screenshots/RTLArabicLearnerDashboard.png differ
diff --git a/core/tests/puppeteer-acceptance-tests/specs/logged-in-learner/export-and-delete-account.spec.ts b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/export-and-delete-account.spec.ts
similarity index 79%
rename from core/tests/puppeteer-acceptance-tests/specs/logged-in-learner/export-and-delete-account.spec.ts
rename to core/tests/playwright-acceptance-tests/specs/logged-in-learner/export-and-delete-account.spec.ts
index 191f0fb37f8d7..b079ffe0a16a6 100644
--- a/core/tests/puppeteer-acceptance-tests/specs/logged-in-learner/export-and-delete-account.spec.ts
+++ b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/export-and-delete-account.spec.ts
@@ -1,4 +1,4 @@
-// Copyright 2025 The Oppia Authors. All Rights Reserved.
+// Copyright 2026 The Oppia Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -19,26 +19,30 @@
* LI.PP. Learner Export or Delete the Account from preference page.
*/
+import {test} from '@playwright/test';
import {UserFactory} from '../../utilities/common/user-factory';
import {LoggedInUser} from '../../utilities/user/logged-in-user';
import {LoggedOutUser} from '../../utilities/user/logged-out-user';
-describe('Logged-In Learner', function () {
+test.describe.configure({mode: 'serial'});
+
+test.describe('Logged-In Learner', function () {
let loggedInLearner: LoggedInUser & LoggedOutUser;
- beforeAll(async function () {
+ test.beforeAll(async function ({browser}) {
loggedInLearner = await UserFactory.createNewUser(
'loggedInLearner',
- 'logged_in_learner@example.com'
+ 'logged_in_learner@example.com',
+ browser
);
});
- it('should be able to export account', async function () {
+ test('should be able to export account', async function () {
await loggedInLearner.navigateToPreferencesPageUsingProfileDropdown();
await loggedInLearner.exportAccount();
});
- it('should be able to delete account', async function () {
+ test('should be able to delete account', async function () {
// Delete Account.
await loggedInLearner.deleteAccount();
// Initiating account deletion from /preferences page redirects to /delete-account page.
@@ -49,7 +53,7 @@ describe('Logged-In Learner', function () {
await loggedInLearner.expectToBeOnPage('pending account deletion');
});
- afterAll(async function () {
+ test.afterAll(async function () {
await UserFactory.closeAllBrowsers();
});
});
diff --git a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/prod-desktop-screenshots/RTLArabicAboutPage.png b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/prod-desktop-screenshots/RTLArabicAboutPage.png
new file mode 100644
index 0000000000000..7771a8822beda
Binary files /dev/null and b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/prod-desktop-screenshots/RTLArabicAboutPage.png differ
diff --git a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/prod-desktop-screenshots/RTLArabicHomePage.png b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/prod-desktop-screenshots/RTLArabicHomePage.png
new file mode 100644
index 0000000000000..8afb62b100d12
Binary files /dev/null and b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/prod-desktop-screenshots/RTLArabicHomePage.png differ
diff --git a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/prod-desktop-screenshots/RTLArabicLearnerDashboard.png b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/prod-desktop-screenshots/RTLArabicLearnerDashboard.png
new file mode 100644
index 0000000000000..07dab0a6fc9b6
Binary files /dev/null and b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/prod-desktop-screenshots/RTLArabicLearnerDashboard.png differ
diff --git a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/prod-mobile-screenshots/RTLArabicAboutPage.png b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/prod-mobile-screenshots/RTLArabicAboutPage.png
new file mode 100644
index 0000000000000..3fa59f42baa28
Binary files /dev/null and b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/prod-mobile-screenshots/RTLArabicAboutPage.png differ
diff --git a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/prod-mobile-screenshots/RTLArabicHomePage.png b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/prod-mobile-screenshots/RTLArabicHomePage.png
new file mode 100644
index 0000000000000..172d83d7a163e
Binary files /dev/null and b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/prod-mobile-screenshots/RTLArabicHomePage.png differ
diff --git a/core/tests/playwright-acceptance-tests/specs/logged-in-learner/prod-mobile-screenshots/RTLArabicLearnerDashboard.png b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/prod-mobile-screenshots/RTLArabicLearnerDashboard.png
new file mode 100644
index 0000000000000..e43ca32ad0c8c
Binary files /dev/null and b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/prod-mobile-screenshots/RTLArabicLearnerDashboard.png differ
diff --git a/core/tests/puppeteer-acceptance-tests/specs/logged-in-learner/reaches-a-checkpoint-and-saves-their-progress.spec.ts b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/reaches-a-checkpoint-and-saves-their-progress.spec.ts
similarity index 87%
rename from core/tests/puppeteer-acceptance-tests/specs/logged-in-learner/reaches-a-checkpoint-and-saves-their-progress.spec.ts
rename to core/tests/playwright-acceptance-tests/specs/logged-in-learner/reaches-a-checkpoint-and-saves-their-progress.spec.ts
index af44dbdb137f3..618580513d95f 100644
--- a/core/tests/puppeteer-acceptance-tests/specs/logged-in-learner/reaches-a-checkpoint-and-saves-their-progress.spec.ts
+++ b/core/tests/playwright-acceptance-tests/specs/logged-in-learner/reaches-a-checkpoint-and-saves-their-progress.spec.ts
@@ -1,4 +1,4 @@
-// Copyright 2025 The Oppia Authors. All Rights Reserved.
+// Copyright 2026 The Oppia Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -19,7 +19,7 @@
* LI. Learner reaches a checkpoint and saves their progress
*/
-import testConstants from '../../utilities/common/test-constants';
+import {test} from '@playwright/test';
import {UserFactory} from '../../utilities/common/user-factory';
import {
ExplorationEditor,
@@ -28,16 +28,15 @@ import {
import {LoggedInUser} from '../../utilities/user/logged-in-user';
import {LoggedOutUser} from '../../utilities/user/logged-out-user';
-const DEFAULT_SPEC_TIMEOUT_MSECS = testConstants.DEFAULT_SPEC_TIMEOUT_MSECS;
-
-describe('Logged-in User', function () {
+test.describe('Logged-in User', function () {
let explorationEditor: ExplorationEditor;
let loggedInUser: LoggedInUser & LoggedOutUser;
- beforeAll(async function () {
+ test.beforeAll(async function ({browser}) {
explorationEditor = await UserFactory.createNewUser(
'explorationEditor',
- 'exploration_editor@example.com'
+ 'exploration_editor@example.com',
+ browser
);
await explorationEditor.navigateToCreatorDashboardPage();
@@ -100,11 +99,12 @@ describe('Logged-in User', function () {
loggedInUser = await UserFactory.createNewUser(
'loggedInUser',
- 'logged_in_user@example.com'
+ 'logged_in_user@example.com',
+ browser
);
- }, DEFAULT_SPEC_TIMEOUT_MSECS);
+ });
- it('should be able to track the checkpoint progress', async function () {
+ test('should be able to track the checkpoint progress', async function () {
await loggedInUser.navigateToCommunityLibraryPage();
await loggedInUser.searchForLessonInSearchBar('Positive Numbers');
await loggedInUser.playLessonFromSearchResults('Positive Numbers');
@@ -118,7 +118,7 @@ describe('Logged-in User', function () {
await loggedInUser.verifyCheckpointModalAppears();
});
- it('should be able to resume the lesson from the last progress saved', async function () {
+ test('should be able to resume the lesson from the last progress saved', async function () {
// Again reload the page to check the 'Resume' exploration in the progress remainder as well.
await loggedInUser.reloadPage();
await loggedInUser.expectProgressReminder(true);
@@ -130,7 +130,7 @@ describe('Logged-in User', function () {
);
});
- it('should be able to restart the lesson from the beginning', async function () {
+ test('should be able to restart the lesson from the beginning', async function () {
// Reloading from the current progress.
await loggedInUser.reloadPage();
@@ -143,7 +143,7 @@ describe('Logged-in User', function () {
await loggedInUser.continueToNextCard();
});
- afterAll(async function () {
+ test.afterAll(async function () {
await UserFactory.closeAllBrowsers();
- }, DEFAULT_SPEC_TIMEOUT_MSECS);
+ });
});
diff --git a/core/tests/playwright-acceptance-tests/utilities/common/playwright-utils.ts b/core/tests/playwright-acceptance-tests/utilities/common/playwright-utils.ts
index fcd6f4eba7741..a3acb9dcd567a 100644
--- a/core/tests/playwright-acceptance-tests/utilities/common/playwright-utils.ts
+++ b/core/tests/playwright-acceptance-tests/utilities/common/playwright-utils.ts
@@ -17,10 +17,14 @@
*/
import {ViewportSize} from '@playwright/test';
-import {Page, ElementHandle} from '@playwright/test';
+import test, {expect, Page, ElementHandle} from '@playwright/test';
import isElementClickable from '../../functions/is-element-clickable';
import testConstants from './test-constants';
import {showMessage} from './show-message';
+import fs from 'fs';
+
+const backgroundBanner = '.oppia-background-image';
+const libraryBanner = '.e2e-test-library-banner';
const toastMessageSelector = '.e2e-test-toast-message';
@@ -339,6 +343,31 @@ export class BaseUser {
await this.page.keyboard.press('Backspace');
}
+ /**
+ * Checks if element is clickable or not.
+ */
+ async expectElementToBeClickable(
+ selector: string | ElementHandle,
+ clickable: boolean = true
+ ): Promise {
+ const element =
+ typeof selector === 'string'
+ ? await this.page.waitForSelector(selector)
+ : selector;
+ await this.page.waitForFunction(
+ ({element, clickable, clickableFn}) => {
+ const fn = new Function(
+ 'element',
+ 'clickable',
+ `return (${clickableFn})(element, clickable)`
+ );
+ return fn(element, clickable);
+ },
+ {element, clickable, clickableFn: isElementClickable.toString()},
+ {timeout: 30000}
+ );
+ }
+
/**
* Waits for the given element to be visible, and then checks if the text
* content matches the expected text.
@@ -448,6 +477,74 @@ export class BaseUser {
await this.expectElementToBeVisible(toastMessageSelector, false);
}
+ /**
+ * This function checks if the page URL contains the given URL.
+ * @param {string} url - The URL to check.
+ * @param {Page} context - The page on which the URL should be checked.
+ */
+ async expectPageURLToContain(
+ url: string,
+ context: Page = this.page
+ ): Promise {
+ await context.waitForFunction((url: string) => {
+ return window.location.href.includes(url);
+ }, url);
+ }
+
+ /**
+ * This function compares the current page screenshot with a reference image.
+ * @param {string} imageName - The name for the image
+ * @param {Page|undefined} newPage - The page to take screenshot from. If not
+ * specified, uses this.page instead.
+ * @param {Parameters[0]} options - Additional options for the screenshot comparison.
+ */
+ async expectScreenshotToMatch(
+ imageName: string,
+ newPage: Page | undefined = undefined,
+ options: Parameters[0] = {}
+ ): Promise {
+ const currentPage = typeof newPage !== 'undefined' ? newPage : this.page;
+ await currentPage.mouse.move(-1, -1);
+ await currentPage.waitForTimeout(5000);
+
+ const snapshotPath = test
+ .info()
+ .snapshotPath(`${imageName}.png`, {kind: 'screenshot'});
+
+ if (
+ !fs.existsSync(snapshotPath) &&
+ process.env.UPDATE_SNAPSHOTS !== 'true'
+ ) {
+ throw new Error(
+ `Missing baseline snapshot: ${imageName}.png at ${snapshotPath}. ` +
+ 'Run with --update_snapshots to generate it.'
+ );
+ }
+
+ let failureTrigger = 0;
+
+ if (this.isViewportAtMobileWidth()) {
+ failureTrigger += 0.048;
+ if (await currentPage.$(backgroundBanner)) {
+ failureTrigger += 0.0352;
+ } else if (await currentPage.$(libraryBanner)) {
+ failureTrigger += 0.0039;
+ }
+ } else {
+ failureTrigger += 0.04;
+ if (await currentPage.$(backgroundBanner)) {
+ failureTrigger += 0.03;
+ } else if (await currentPage.$(libraryBanner)) {
+ failureTrigger += 0.006;
+ }
+ }
+
+ await expect(currentPage).toHaveScreenshot(`${imageName}.png`, {
+ maxDiffPixelRatio: failureTrigger,
+ ...options,
+ });
+ }
+
/**
* Function to find an element by its CSS selector.
* @param {string} selector - The CSS selector of the element.
diff --git a/core/tests/playwright-acceptance-tests/utilities/user/curriculum-admin.ts b/core/tests/playwright-acceptance-tests/utilities/user/curriculum-admin.ts
index 38c9355f3462b..f2a70f1ac775b 100644
--- a/core/tests/playwright-acceptance-tests/utilities/user/curriculum-admin.ts
+++ b/core/tests/playwright-acceptance-tests/utilities/user/curriculum-admin.ts
@@ -621,6 +621,61 @@ export class CurriculumAdmin extends TopicManager {
await this.publishClassroom(classroomName);
}
+ /**
+ * Create a story, execute chapter creation for
+ * the story, and then publish the story.
+ */
+ async createAndPublishStoryWithChapter(
+ storyTitle: string,
+ storyUrlFragment: string,
+ chapterTitle: string,
+ explorationId: string,
+ topicName?: string
+ ): Promise {
+ if (topicName) {
+ await this.openTopicEditor(topicName);
+ }
+ if (this.isViewportAtMobileWidth()) {
+ await this.clickOnElementWithSelector(mobileStoryDropdown);
+ }
+ await this.clickOnElementWithSelector(addStoryButton);
+ await this.typeInInputField(storyTitleField, storyTitle);
+ await this.page.waitForSelector(storyUrlFragmentField, {
+ state: 'visible',
+ });
+ await this.page.type(storyUrlFragmentField, storyUrlFragment);
+ await this.typeInInputField(
+ storyDescriptionField,
+ `Story creation description for ${storyTitle}.`
+ );
+
+ await this.clickOnElementWithSelector(storyPhotoBoxButton);
+ await this.uploadFile(curriculumAdminThumbnailImage);
+ await this.page.waitForSelector(`${uploadPhotoButton}:not([disabled])`);
+ await this.clickOnElementWithSelector(uploadPhotoButton);
+
+ await this.page.waitForSelector(photoUploadModal, {state: 'hidden'});
+ await this.clickAndWaitForNavigation(createStoryButton, true);
+
+ await this.page.waitForSelector(storyMetaTagInput);
+ await this.page.focus(storyMetaTagInput);
+ await this.page.type(storyMetaTagInput, 'meta');
+ await this.page.keyboard.press('Tab');
+
+ await this.addChapter(chapterTitle, explorationId);
+
+ await this.saveStoryDraft();
+ if (this.isViewportAtMobileWidth()) {
+ await this.clickOnElementWithSelector(mobileSaveStoryChangesDropdown);
+ await this.page.waitForSelector(mobilePublishStoryButton);
+ await this.clickOnElementWithSelector(mobilePublishStoryButton);
+ } else {
+ await this.page.waitForSelector(`${publishStoryButton}:not([disabled])`);
+ await this.clickOnElementWithSelector(publishStoryButton);
+ await this.page.waitForSelector(unpublishStoryButton, {state: 'visible'});
+ }
+ }
+
/**
* Creates and publishes a topic with a subtopic and skill.
* @param {string} topicName - The name of the topic.
diff --git a/core/tests/playwright-acceptance-tests/utilities/user/exploration-editor.ts b/core/tests/playwright-acceptance-tests/utilities/user/exploration-editor.ts
index 73f250851748e..bb2bf55a0f02a 100644
--- a/core/tests/playwright-acceptance-tests/utilities/user/exploration-editor.ts
+++ b/core/tests/playwright-acceptance-tests/utilities/user/exploration-editor.ts
@@ -33,6 +33,14 @@ const saveInteractionButton = 'button.e2e-test-save-interaction';
const saveChangesButton = 'button.e2e-test-save-changes';
const addInteractionModalSelector = 'customize-interaction-body-container';
+const addHintButton = 'button.e2e-test-oppia-add-hint-button';
+const saveHintButton = 'button.e2e-test-save-hint';
+const solutionInputNumeric = 'oppia-add-or-update-solution-modal input';
+const solutionInputTextArea =
+ 'oppia-add-or-update-solution-modal textarea.e2e-test-description-box';
+const addSolutionButton = 'button.e2e-test-oppia-add-solution-button';
+const submitAnswerButton = '.e2e-test-submit-answer-button';
+const submitSolutionButton = 'button.e2e-test-submit-solution-button';
const saveDraftButton = 'button.e2e-test-save-draft-button';
const commitMessageSelector = 'textarea.e2e-test-commit-message-input';
@@ -41,6 +49,7 @@ const explorationTitleInput = 'input.e2e-test-exploration-title-input-modal';
const explorationGoalInput = 'input.e2e-test-exploration-objective-input-modal';
const explorationCategoryDropdown =
'mat-form-field.e2e-test-exploration-category-metadata-modal';
+const setAsCheckpointButton = '.e2e-test-checkpoint-selection-checkbox';
const saveExplorationChangesButton = 'button.e2e-test-confirm-pre-publication';
const explorationConfirmPublishButton = '.e2e-test-confirm-publish';
@@ -164,6 +173,22 @@ export class ExplorationEditor extends BaseUser {
showMessage('Creator dashboard page is opened successfully.');
}
+ /**
+ * Function to add a hint for a state card.
+ * @param {string} hint - The hint to be added for the current card.
+ */
+ async addHintToState(hint: string): Promise {
+ await this.page.waitForSelector(addHintButton, {
+ state: 'visible',
+ });
+ await this.clickOnElementWithSelector(addHintButton);
+ await this.typeInInputField(stateContentInputField, hint);
+ await this.clickOnElementWithSelector(saveHintButton);
+ await this.page.waitForSelector(saveHintButton, {
+ state: 'hidden',
+ });
+ }
+
/**
* Function to add an interaction to the exploration.
* @param {string} interactionToAdd - The interaction type to add to the Exploration.
@@ -291,6 +316,47 @@ export class ExplorationEditor extends BaseUser {
);
}
+ /**
+ * Function to add a solution for a state interaction.
+ * @param {string} answer - The solution of the current state card.
+ * @param {string} answerExplanation - The explanation for this state card's solution.
+ * @param {boolean} isSolutionNumericInput - Whether the solution is for a numeric input interaction.
+ */
+ async addSolutionToState(
+ answer: string,
+ answerExplanation: string,
+ isSolutionNumericInput: boolean
+ ): Promise {
+ await this.expectElementToBeVisible(addSolutionButton);
+ await this.clickOnElementWithSelector(addSolutionButton);
+
+ const solutionSelector = isSolutionNumericInput
+ ? solutionInputNumeric
+ : solutionInputTextArea;
+ await this.page.waitForSelector(solutionSelector, {state: 'visible'});
+ await this.typeInInputField(solutionSelector, answer);
+ await this.page.waitForSelector(`${submitAnswerButton}:not([disabled])`);
+ await this.clickOnElementWithSelector(submitAnswerButton);
+ await this.typeInInputField(stateContentInputField, answerExplanation);
+ await this.page.waitForSelector(`${submitSolutionButton}:not([disabled])`);
+ await this.clickOnElementWithSelector(submitSolutionButton);
+
+ await this.expectElementToBeVisible(submitSolutionButton, false);
+ }
+
+ /**
+ * Adds a solution explanation to the current state card and saves it.
+ * @param explanation - The solution explanation to add to the state card.
+ */
+ async addSolutionExplanationAndSave(explanation: string): Promise {
+ await this.typeInInputField(stateContentInputField, explanation);
+ await this.page.waitForSelector(`${submitSolutionButton}:not([disabled])`);
+ await this.clickOnElementWithSelector(submitSolutionButton);
+ await this.page.waitForSelector(submitSolutionButton, {
+ state: 'hidden',
+ });
+ }
+
/**
* Changes tab in interaction selection modal.
* @param interactionType Interaction type to change tab.
@@ -504,6 +570,21 @@ export class ExplorationEditor extends BaseUser {
);
}
+ /**
+ * Function to Get the type of an input field in the DOM.
+ * @param {string} selector - The CSS selector for the input field.
+ */
+ async getInputType(selector: string): Promise {
+ const inputField = await this.page.$(selector);
+ if (!inputField) {
+ throw new Error(`Input field not found for selector: ${selector}`);
+ }
+ const inputType = (await (
+ await inputField.getProperty('type')
+ ).jsonValue()) as string;
+ return inputType;
+ }
+
/**
* Function to navigate to a specific card in the exploration.
* @param {string} cardName - The name of the card to navigate to.
@@ -674,6 +755,34 @@ export class ExplorationEditor extends BaseUser {
await this.waitForPageToFullyLoad();
}
+ /**
+ * Sets a state as a checkpoint in the exploration.
+ */
+ async setTheStateAsCheckpoint(): Promise {
+ await this.page.waitForSelector(setAsCheckpointButton, {
+ state: 'visible',
+ });
+
+ let checkboxState = await this.page.$eval(
+ `${setAsCheckpointButton} input.mat-checkbox-input`,
+ el => (el as HTMLInputElement).checked
+ );
+
+ if (!checkboxState) {
+ await this.clickOnElementWithSelector(setAsCheckpointButton);
+ }
+
+ // Check checkbox value again and throw error if it's still not checked.
+ checkboxState = await this.page.$eval(
+ `${setAsCheckpointButton} input.mat-checkbox-input`,
+ el => (el as HTMLInputElement).checked
+ );
+
+ if (!checkboxState) {
+ throw new Error('Failed to set the state as a checkpoint.');
+ }
+ }
+
/**
* Function to publish exploration.
* This is a composite function that can be used when a straightforward, simple exploration published is required.
diff --git a/core/tests/playwright-acceptance-tests/utilities/user/logged-in-user.ts b/core/tests/playwright-acceptance-tests/utilities/user/logged-in-user.ts
index b33e7cd5c923e..3c890b81735cf 100644
--- a/core/tests/playwright-acceptance-tests/utilities/user/logged-in-user.ts
+++ b/core/tests/playwright-acceptance-tests/utilities/user/logged-in-user.ts
@@ -49,6 +49,7 @@ const feedbackTextareaSelector = '.e2e-test-exploration-feedback-textarea';
const submitButtonSelector = '.e2e-test-exploration-feedback-submit-btn';
const submittedMessageSelector = '.e2e-test-rating-submitted-message';
+const angularRootElementSelector = 'oppia-angular-root';
const homeTabSectionInLearnerDashboard = '.e2e-test-learner-dash-home-tab';
const explorationCard = '.e2e-test-exploration-dashboard-card';
const desktopLessonCardTitleSelector = '.e2e-test-exploration-tile-title';
@@ -67,6 +68,21 @@ const mobileLessonCardOptionsDropdownButton =
'.e2e-test-mobile-lesson-card-dropdown';
const progressSectionSelector = '.e2e-test-progress-section';
const greetingSelector = '.e2e-learner-dashboard-greeting';
+const exportButtonSelector = '.e2e-test-export-account-button';
+
+// Preferences page selectors.
+const confirmUsernameField = '.e2e-test-confirm-username-field';
+const confirmAccountDeletionButton = '.e2e-test-confirm-deletion-button';
+const deleteAccountPage = '.e2e-test-delete-account';
+const deleteAccountButton = '.e2e-test-delete-account-button';
+const deleteMyAcccountButton = '.e2e-test-delete-my-account-button';
+const accountDeletionButtonInDeleteAccountPage =
+ '.e2e-test-delete-my-account-button';
+const preferencesContainerSelector = '.e2e-test-preferences-container';
+const preferencesMenuLink = '.e2e-test-preferences-link';
+const ACCOUNT_EXPORT_CONFIRMATION_MESSAGE =
+ 'Your data is currently being loaded and will be downloaded as a JSON formatted text file upon completion.';
+const ACCOUNT_EXPORT_CONFIRMATION_MESSAGE_2 = 'Please do not leave this page.';
const ratingsHeaderSelector = '.conversation-skin-final-ratings-header';
const ratingStarSelector = '.e2e-test-rating-star';
@@ -315,6 +331,37 @@ export class LoggedInUser extends BaseUser {
}
}
+ /**
+ * Clicks the delete account button and waits for navigation.
+ */
+ async deleteAccount(): Promise {
+ await this.clickAndWaitForNavigation(deleteAccountButton, true);
+
+ await this.page.waitForSelector(deleteAccountPage, {
+ state: 'visible',
+ });
+ }
+
+ /**
+ * Clicks on the delete button in the page /delete-account to confirm account deletion, also, for confirmation username needs to be entered.
+ * @param {string} username - The username of the account.
+ */
+ async confirmAccountDeletion(username: string): Promise {
+ await this.page.waitForSelector(accountDeletionButtonInDeleteAccountPage, {
+ state: 'visible',
+ });
+ await this.clickOnElementWithSelector(
+ accountDeletionButtonInDeleteAccountPage
+ );
+ await this.typeInInputField(confirmUsernameField, username);
+ await this.clickAndWaitForNavigation(confirmAccountDeletionButton, true);
+
+ await this.page.waitForSelector(deleteMyAcccountButton, {
+ state: 'hidden',
+ });
+ showMessage(`Account deleted for ${username}.`);
+ }
+
/**
* Function to enter email and proceed to the next page (username page).
* This will click "Sign In" and verify the username field is visible.
@@ -346,6 +393,63 @@ export class LoggedInUser extends BaseUser {
}
}
+ /**
+ * Verifies that the current page URL includes the expected page pathname.
+ */
+ async expectToBeOnPage(expectedPage: string): Promise {
+ await this.waitForStaticAssetsToLoad();
+ const url = this.page.url();
+
+ // Replace spaces in the expectedPage with hyphens.
+ const expectedPageInUrl = expectedPage.replace(/\s+/g, '-');
+
+ if (!url.includes(expectedPageInUrl.toLowerCase())) {
+ throw new Error(
+ `Expected to be on page ${expectedPage}, but found ${url}`
+ );
+ }
+ }
+
+ /**
+ * Exports the user's account data.
+ */
+ async exportAccount(): Promise {
+ try {
+ await this.page.waitForSelector(exportButtonSelector);
+ const exportButton = await this.page.$(exportButtonSelector);
+
+ if (!exportButton) {
+ throw new Error('Export button not found');
+ }
+
+ await this.waitForPageToFullyLoad();
+ await exportButton.click();
+
+ const isTextPresent = await this.isTextPresentOnPage(
+ ACCOUNT_EXPORT_CONFIRMATION_MESSAGE
+ );
+
+ const isTextPresent2 = await this.isTextPresentOnPage(
+ ACCOUNT_EXPORT_CONFIRMATION_MESSAGE_2
+ );
+
+ if (!isTextPresent) {
+ throw new Error(
+ `Expected text not found on page: ${ACCOUNT_EXPORT_CONFIRMATION_MESSAGE}`
+ );
+ }
+ if (!isTextPresent2) {
+ throw new Error(
+ `Expected text not found on page: ${ACCOUNT_EXPORT_CONFIRMATION_MESSAGE_2}`
+ );
+ }
+ } catch (error) {
+ const newError = new Error(`Failed to export account: ${error}`);
+ newError.stack = (error as Error).stack;
+ throw newError;
+ }
+ }
+
/**
* Navigates to the learner dashboard.
*/
@@ -425,6 +529,25 @@ export class LoggedInUser extends BaseUser {
await this.goto(moderatorPageUrl);
}
+ /**
+ * Navigates to the Preferences Page Using Profile Dropdown Menu.
+ */
+ async navigateToPreferencesPageUsingProfileDropdown(): Promise {
+ await this.page.waitForSelector(profileDropdown, {
+ state: 'visible',
+ });
+ await this.clickOnElementWithSelector(profileDropdown);
+
+ await this.page.waitForSelector(preferencesMenuLink, {
+ state: 'visible',
+ });
+ await this.clickOnElementWithSelector(preferencesMenuLink);
+
+ await this.page.waitForSelector(preferencesContainerSelector, {
+ state: 'visible',
+ });
+ }
+
/**
* Navigates to the Release Coordinator page.
*/
@@ -1195,6 +1318,32 @@ export class LoggedInUser extends BaseUser {
throw newError;
}
}
+
+ /**
+ * Verifies if the page is displayed in Right-to-Left (RTL) mode.
+ */
+ async verifyPageIsRTL(): Promise {
+ await this.page.waitForSelector(angularRootElementSelector);
+ const pageDirection = await this.page.evaluate(selector => {
+ const oppiaRoot = document.querySelector(selector);
+ if (!oppiaRoot) {
+ throw new Error(`${selector} not found`);
+ }
+
+ const childDiv = oppiaRoot.querySelector('div');
+ if (!childDiv) {
+ throw new Error('Child div not found');
+ }
+
+ return childDiv.getAttribute('dir');
+ }, angularRootElementSelector);
+
+ if (pageDirection !== 'rtl') {
+ throw new Error('Page is not in RTL mode');
+ }
+
+ showMessage('Page is displayed in RTL mode.');
+ }
}
export const LoggedInUserFactory = (page: Page): LoggedInUser => {
diff --git a/core/tests/playwright-acceptance-tests/utilities/user/logged-out-user.ts b/core/tests/playwright-acceptance-tests/utilities/user/logged-out-user.ts
index 7a1c2360c85d9..62eda989c3f09 100644
--- a/core/tests/playwright-acceptance-tests/utilities/user/logged-out-user.ts
+++ b/core/tests/playwright-acceptance-tests/utilities/user/logged-out-user.ts
@@ -16,20 +16,29 @@
* @fileoverview Logged-out users utility file.
*/
-import {Page} from '@playwright/test';
+import {expect, Page} from '@playwright/test';
import {BaseUser} from '../common/playwright-utils';
import {showMessage} from '../common/show-message';
import testConstants from '../common/test-constants';
import isElementClickable from '../../functions/is-element-clickable';
+const aboutUrl = testConstants.URLs.About;
const communityLibraryUrl = testConstants.URLs.CommunityLibrary;
+const homeUrl = testConstants.URLs.Home;
const signUpUsernameInputField = 'input.e2e-test-username-input';
+const mobileNavbarButtonSelector = '.text-uppercase';
const navbarLearnTab = 'a.e2e-test-navbar-learn-menu';
+const languageDropdown = '.e2e-test-language-dropdown';
+const navbarAboutTab = 'a.e2e-test-navbar-about-menu';
+const navbarAboutTabAboutButton = 'a.e2e-test-about-link';
const mobileNavbarOpenSidebarButton = 'a.e2e-mobile-test-navbar-button';
const mobileSidebarOpenSelector = '.e2e-test-sidebar-menu-open';
+const mobileSidebarExpandAboutMenuButton =
+ 'div.e2e-mobile-test-sidebar-expand-about-menu';
+const mobileSidebarAboutButton = 'a.e2e-mobile-test-sidebar-about-button';
const nextCardButton = '.e2e-test-next-card-button';
const nextCardArrowButton = '.e2e-test-next-button';
@@ -41,6 +50,17 @@ const stateConversationContent = '.e2e-test-conversation-content';
const searchInputSelector = '.e2e-test-search-input';
const lessonCardTitleSelector = '.e2e-test-exploration-tile-title';
+const resumeExplorationButton = '.resume-button';
+const restartExplorationButton = '.restart-button';
+const submitAnswerButton = '.e2e-test-submit-answer-button';
+const submitResponseToInteractionInput = 'oppia-interaction-display input';
+
+const previousConversationToggleSelector = '.e2e-test-previous-responses-text';
+const formErrorContainer = '.e2e-test-form-error-container';
+const checkpointModalSelector = '.lesson-info-tooltip-add-ons';
+const closeLessonInfoTooltipSelector = '.e2e-test-close-lesson-info-tooltip';
+const progressRemainderModalSelector = '.oppia-progress-reminder-modal';
+
const communityLibraryLinkInNavbarSelector =
'.e2e-test-topnb-go-to-community-library-link';
const communityLibraryContainerSelector = '.e2e-test-library-container';
@@ -48,6 +68,11 @@ const communityLibraryLinkInNavMenuSelector = '.e2e-mobile-test-library-link';
const returnToLibraryButtonSelector = '.e2e-test-exploration-return-to-library';
+const lessonInfoButton = '.oppia-lesson-info';
+const lessonInfoCardSelector = '.oppia-lesson-info-card';
+const hintButtonSelector = '.e2e-test-view-hint';
+const gotItButtonSelector = '.e2e-test-learner-got-it-button';
+
export class LoggedOutUser extends BaseUser {
/**
* Clears all text from the username input field.
@@ -56,6 +81,202 @@ export class LoggedOutUser extends BaseUser {
await this.clearAllTextFrom(signUpUsernameInputField);
}
+ /**
+ * Function to change the site language to the given language code.
+ * @param langCode - The language code to change the site language to. Example: 'pt-br', 'en'
+ */
+ async changeSiteLanguage(langCode: string): Promise {
+ const languageOption = `.e2e-test-i18n-language-${langCode} a`;
+
+ if (this.isViewportAtMobileWidth()) {
+ // This is required to ensure the language dropdown is visible in mobile view,
+ // if the earlier movements of the page have hidden it and since the inbuilt
+ // scrollIntoView function call of the clickOn function didn't work as expected.
+ await this.page.evaluate(() => {
+ window.scrollTo(0, 0);
+ });
+ }
+ const languageDropdownElement = await this.page.waitForSelector(
+ languageDropdown,
+ {state: 'visible'}
+ );
+ if (!languageDropdownElement) {
+ throw new Error('Language dropdown element not found');
+ }
+ const initialLanguage = await this.page.$eval(
+ languageDropdown,
+ el => el.textContent
+ );
+ await this.clickOnElement(languageDropdownElement);
+ // Capture the navigation the language click triggers before reloading.
+ await this.clickAndWaitForNavigation(languageOption, true);
+ // Here we need to reload the page again to confirm the language change.
+ await this.page.reload();
+
+ await this.page.waitForFunction(
+ ({selector, textContent}: {selector: string; textContent: string}) => {
+ const element = document.querySelector(selector);
+ return element && element.textContent !== textContent;
+ },
+ {selector: languageOption, textContent: initialLanguage}
+ );
+ }
+
+ /**
+ * Chooses an action in the progress remainder.
+ * @param {string} action - The action to choose. Can be 'Restart' or 'Resume'.
+ */
+ async chooseActionInProgressRemainder(
+ action: 'Restart' | 'Resume'
+ ): Promise {
+ await this.page.waitForSelector(progressRemainderModalSelector, {
+ state: 'visible',
+ });
+ await this.page.waitForSelector(restartExplorationButton, {
+ state: 'visible',
+ });
+ await this.page.waitForSelector(resumeExplorationButton, {
+ state: 'visible',
+ });
+
+ if (action === 'Restart') {
+ await this.clickAndWaitForNavigation(restartExplorationButton, true);
+ } else if (action === 'Resume') {
+ await this.clickOnElementWithSelector(resumeExplorationButton);
+ // Closing checkpoint modal if appears.
+ const closeLessonInfoTooltipElement = await this.page.$(
+ closeLessonInfoTooltipSelector
+ );
+ if (closeLessonInfoTooltipElement) {
+ await this.clickOnElementWithSelector(closeLessonInfoTooltipSelector);
+ }
+ } else {
+ throw new Error(
+ `Invalid action: ${action}. Expected 'Restart' or 'Resume'.`
+ );
+ }
+ }
+
+ /**
+ * Function to click the About button in the About Menu on navbar
+ * and check if it opens the About page.
+ */
+ async clickAboutButtonInAboutMenuOnNavbar(): Promise {
+ if (this.isViewportAtMobileWidth()) {
+ await this.page.waitForSelector(mobileNavbarButtonSelector, {
+ state: 'visible',
+ });
+ await this.openMobileSidebar();
+
+ // Wait for Angular to be stable before clicking the expand button.
+ await this.waitForAngularStability();
+
+ // Use JavaScript click for sidebar menu items.
+ await this.clickWithJavaScript(mobileSidebarExpandAboutMenuButton);
+
+ // Wait for the About submenu to expand and the About button to be visible.
+ await this.page.waitForSelector(mobileSidebarAboutButton, {
+ state: 'visible',
+ });
+ await this.clickButtonToNavigateToNewPage(
+ mobileSidebarAboutButton,
+ aboutUrl
+ );
+ } else {
+ await this.page.waitForSelector(navbarAboutTab, {
+ state: 'visible',
+ });
+ await this.clickOnElementWithSelector(navbarAboutTab);
+ await this.clickButtonToNavigateToNewPage(
+ navbarAboutTabAboutButton,
+ aboutUrl
+ );
+ }
+ }
+
+ /**
+ * Function to click a button and check if it opens the expected destination.
+ */
+ private async clickButtonToNavigateToNewPage(
+ button: string,
+ expectedDestinationPageUrl: string,
+ useSelector: boolean = true
+ ): Promise {
+ await this.clickAndWaitForNavigation(button, useSelector);
+ await this.expectPageURLToContain(expectedDestinationPageUrl);
+ }
+
+ /**
+ * Click on the submit answer button.
+ * @param skipVerification - If true, skips verification that the button is visible.
+ */
+ async clickOnSubmitAnswerButton(): Promise {
+ const feedbackSelector = '.e2e-test-conversation-feedback-latest';
+
+ await this.expectElementToBeClickable(submitAnswerButton);
+
+ // Get current status of old and latest responses to use it later.
+ // Handle cases where elements might not exist.
+ const initialPreviousResponses = await this.page
+ .$eval(
+ previousConversationToggleSelector,
+ element => element?.textContent?.trim() || null
+ )
+ .catch(() => null);
+
+ const initialLatestResponse = await this.page
+ .$eval(feedbackSelector, element => element?.textContent?.trim() || null)
+ .catch(() => null);
+
+ // Wait for 1s to ensure the selected answer is updated in Angular component.
+ await this.page.waitForTimeout(1000);
+ // Click on Submit Answer button.
+ await this.clickOnElementWithSelector(submitAnswerButton);
+
+ // Wait for either element to change content.
+ await this.page.waitForFunction(
+ ({
+ submitButtonSelector,
+ formErrorContainer,
+ selector1,
+ value1,
+ selector2,
+ value2,
+ }: {
+ submitButtonSelector: string;
+ formErrorContainer: string;
+ selector1: string;
+ value1: string | null;
+ selector2: string;
+ value2: string | null;
+ }) => {
+ const submitButton = document.querySelector(submitButtonSelector);
+ const element1 = document.querySelector(selector1);
+ const element2 = document.querySelector(selector2);
+
+ const currentValue1 = element1?.textContent?.trim() || null;
+ const currentValue2 = element2?.textContent?.trim() || null;
+
+ return (
+ (submitButton as HTMLButtonElement)?.disabled ||
+ document.querySelector(formErrorContainer)?.textContent?.trim() !==
+ null ||
+ currentValue1 !== value1 ||
+ currentValue2 !== value2
+ );
+ },
+ {
+ submitButtonSelector: submitAnswerButton,
+ formErrorContainer,
+ selector1: previousConversationToggleSelector,
+ value1: initialPreviousResponses,
+ selector2: feedbackSelector,
+ value2: initialLatestResponse,
+ },
+ {timeout: 10000}
+ );
+ }
+
/**
* Clicks an element using JavaScript's native click() method.
* This ensures Angular properly handles the event in its change detection
@@ -73,6 +294,15 @@ export class LoggedOutUser extends BaseUser {
}, selector);
}
+ /**
+ * Function to close the hint modal.
+ */
+ async closeHintModal(): Promise {
+ await this.page.waitForSelector(gotItButtonSelector, {state: 'visible'});
+ await this.clickOnElementWithSelector(gotItButtonSelector);
+ await this.page.waitForSelector(gotItButtonSelector, {state: 'hidden'});
+ }
+
/**
* Function to navigate to the next card in the preview tab.
*/
@@ -105,6 +335,25 @@ export class LoggedOutUser extends BaseUser {
);
}
+ /**
+ * Checks if the current card's content matches the expected content.
+ * @param {string} expectedCardContent - The expected content of the card.
+ */
+ async expectCardContentToMatch(expectedCardContent: string): Promise {
+ await this.waitForPageToFullyLoad();
+
+ await this.page.waitForSelector(`${stateConversationContent} p`, {
+ state: 'visible',
+ });
+ const element = await this.page.$(`${stateConversationContent} p`);
+ const cardContent = await this.page.evaluate(
+ element => element?.textContent || '',
+ element
+ );
+ expect(cardContent.trim()).toBe(expectedCardContent);
+ showMessage('Card content is as expected.');
+ }
+
/**
* Function to verify if the exploration is completed via checking the toast message.
* @param {string} message - The expected toast message.
@@ -131,6 +380,40 @@ export class LoggedOutUser extends BaseUser {
);
}
+ /**
+ * Checks if the progress remainder is found or not, based on the shouldBeFound parameter. (It can be found when the an already played exploration is revisited or an ongoing exploration is reloaded, but only if the first checkpoint is reached.)
+ * @param {boolean} shouldBeFound - Whether the progress remainder should be found or not.
+ */
+ async expectProgressReminder(shouldBeFound: boolean): Promise {
+ await this.waitForPageToFullyLoad();
+ try {
+ await this.page.waitForSelector(progressRemainderModalSelector, {
+ state: 'visible',
+ });
+ if (!shouldBeFound) {
+ throw new Error('Progress remainder is found, which is not expected.');
+ }
+ showMessage('Progress reminder modal found.');
+ } catch (error) {
+ if (error instanceof Error && error.message.includes('Timeout')) {
+ // Closing checkpoint modal if appears.
+ const closeLessonInfoTooltipElement = await this.page.$(
+ closeLessonInfoTooltipSelector
+ );
+ if (closeLessonInfoTooltipElement) {
+ await this.clickOnElementWithSelector(closeLessonInfoTooltipSelector);
+ }
+ if (shouldBeFound) {
+ throw new Error(
+ 'Progress remainder is not found, which is not expected.'
+ );
+ }
+ } else {
+ throw error;
+ }
+ }
+ }
+
async expectToBeOnCommunityLibraryPage(): Promise {
await this.page.waitForFunction(
(url: string) => window.location.href.includes(url),
@@ -176,6 +459,25 @@ export class LoggedOutUser extends BaseUser {
await this.goto(communityLibraryUrl, verifyURL);
}
+ /**
+ * Function to navigate to the home page.
+ * @param {boolean} verifyURL - Whether to verify the URL after navigation. Defaults to true.
+ */
+ async navigateToHome(verifyURL: boolean = true): Promise {
+ await this.goto(homeUrl, verifyURL);
+ }
+
+ /**
+ * Opens the lesson info modal.
+ */
+ async openLessonInfoModal(): Promise {
+ await this.page.waitForSelector(lessonInfoButton, {
+ state: 'visible',
+ });
+ await this.clickOnElementWithSelector(lessonInfoButton);
+ await this.page.waitForSelector(lessonInfoCardSelector, {state: 'visible'});
+ }
+
/**
* Opens the mobile sidebar and waits for the animation to complete.
* This ensures the sidebar is fully visible before interacting with elements
@@ -335,6 +637,9 @@ export class LoggedOutUser extends BaseUser {
await this.page.waitForSelector(searchInputSelector, {
state: 'visible',
});
+ if (this.isViewportAtMobileWidth()) {
+ await this.page.mouse.move(-1, -1); // Move mouse away to prevent hover effects from blocking the search input.
+ }
await this.clickOnElementWithSelector(searchInputSelector);
await this.typeInInputField(searchInputSelector, lessonName);
@@ -342,6 +647,19 @@ export class LoggedOutUser extends BaseUser {
await this.page.waitForNavigation({waitUntil: 'load'});
}
+ /**
+ * Function to submit an answer to a form input field.
+ * @param {string} answer - The answer to submit.
+ */
+ async submitAnswer(answer: string): Promise {
+ // Allow input elements to be rendered and ready for interaction.
+ await this.page.waitForTimeout(1000);
+ await this.waitForElementToBeClickable(submitResponseToInteractionInput);
+ await this.clearAllTextFrom(submitResponseToInteractionInput);
+ await this.typeInInputField(submitResponseToInteractionInput, answer);
+ await this.clickOnSubmitAnswerButton();
+ }
+
/**
* Types an invalid username in the username input field and blurs it.
* Blur is needed to trigger validation on the input field.
@@ -355,6 +673,45 @@ export class LoggedOutUser extends BaseUser {
(document.querySelector(selector) as HTMLElement)?.blur();
}, signUpUsernameInputField);
}
+
+ /*
+ * Function to verify if the checkpoint modal appears on the screen.
+ */
+ async verifyCheckpointModalAppears(): Promise {
+ try {
+ await this.page.waitForSelector(checkpointModalSelector, {
+ state: 'visible',
+ });
+ showMessage('Checkpoint modal found.');
+ // Closing the checkpoint modal.
+ await this.clickOnElementWithSelector(closeLessonInfoTooltipSelector);
+ await this.page.waitForSelector(checkpointModalSelector, {
+ state: 'hidden',
+ });
+ } catch (error) {
+ if (error instanceof Error && error.message.includes('Timeout')) {
+ const newError = new Error('Checkpoint modal not found.');
+ newError.stack = error.stack;
+ throw newError;
+ }
+ throw error;
+ }
+ }
+
+ /**
+ * Function to use a hint.
+ */
+ async viewHint(): Promise {
+ await this.page.waitForSelector(hintButtonSelector, {
+ // Hint is shown after one minute.
+ timeout: 80000,
+ });
+ await this.clickOnElementWithSelector(hintButtonSelector);
+
+ await this.page.waitForSelector(gotItButtonSelector, {
+ state: 'visible',
+ });
+ }
}
export const LoggedOutUserFactory = (page: Page): LoggedOutUser => {
diff --git a/core/tests/puppeteer-acceptance-tests/specs/exploration-creator/preview-math-interactions.spec.ts b/core/tests/puppeteer-acceptance-tests/specs/exploration-creator/preview-math-interactions.spec.ts
index 04c9f78388135..9fe47825ce39e 100644
--- a/core/tests/puppeteer-acceptance-tests/specs/exploration-creator/preview-math-interactions.spec.ts
+++ b/core/tests/puppeteer-acceptance-tests/specs/exploration-creator/preview-math-interactions.spec.ts
@@ -486,6 +486,14 @@ describe('Exploration Editor', function () {
await explorationEditor.expectResponseFeedbackToBe('Great!');
}, 600000);
+ it('should render Arabic text as a continuous string', async function () {
+ // Navigate to editor tab and update the current card's content
+ // with a math formula containing Arabic text.
+ await explorationEditor.navigateToEditorTab();
+ await explorationEditor.addMathFormulaToCardContent('\\text{سم}', 'سم');
+ await explorationEditor.saveExplorationDraft();
+ });
+
afterAll(async function () {
await UserFactory.closeAllBrowsers();
});
diff --git a/core/tests/puppeteer-acceptance-tests/specs/logged-in-learner/changes-site-language-to-rtl.spec.ts b/core/tests/puppeteer-acceptance-tests/specs/logged-in-learner/changes-site-language-to-rtl.spec.ts
deleted file mode 100644
index 1fafc02a58d39..0000000000000
--- a/core/tests/puppeteer-acceptance-tests/specs/logged-in-learner/changes-site-language-to-rtl.spec.ts
+++ /dev/null
@@ -1,182 +0,0 @@
-// Copyright 2025 The Oppia Authors. All Rights Reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS-IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-/**
- * @fileoverview Acceptance test from CUJv3 Doc
- * https://docs.google.com/document/d/1D7kkFTzg3rxUe3QJ_iPlnxUzBFNElmRkmAWss00nFno/
- *
- * PP. Learner changes the site Language to an RTL (right-to-left) language
- */
-
-import testConstants from '../../utilities/common/test-constants';
-import {UserFactory} from '../../utilities/common/user-factory';
-import {CurriculumAdmin} from '../../utilities/user/curriculum-admin';
-import {ExplorationEditor} from '../../utilities/user/exploration-editor';
-import {LoggedInUser} from '../../utilities/user/logged-in-user';
-import {LoggedOutUser} from '../../utilities/user/logged-out-user';
-
-const ROLES = testConstants.Roles;
-
-describe('Logged-In Learner', function () {
- let loggedInUser1: LoggedInUser & LoggedOutUser;
- let curriculumAdmin: CurriculumAdmin & ExplorationEditor;
- let explorationId: string | null;
-
- beforeAll(
- async function () {
- loggedInUser1 = await UserFactory.createNewUser(
- 'loggedInLearner',
- 'logged_in_learner@example.com'
- );
-
- curriculumAdmin = await UserFactory.createNewUser(
- 'curriculumAdm',
- 'curriculumAdmin@example.com',
- [ROLES.CURRICULUM_ADMIN]
- );
-
- await curriculumAdmin.navigateToCreatorDashboardPage();
- await curriculumAdmin.navigateToExplorationEditorFromCreatorDashboard();
- await curriculumAdmin.dismissWelcomeModal();
- await curriculumAdmin.updateCardContent('Introduction to Fractions');
- await curriculumAdmin.addInteraction('Continue Button');
-
- // Add a new card with a basic algebra problem.
- await curriculumAdmin.viewOppiaResponses();
- await curriculumAdmin.directLearnersToNewCard('Second Card');
- await curriculumAdmin.saveExplorationDraft();
-
- // Navigate to the new card and update its content.
- await curriculumAdmin.navigateToCard('Second Card');
- await curriculumAdmin.updateCardContent('Enter a negative number.');
- await curriculumAdmin.addInteraction('Number Input');
-
- await curriculumAdmin.addResponsesToTheInteraction(
- 'Number Input',
- '-1',
- 'Perfect!',
- 'Last Card',
- true
- );
- await curriculumAdmin.editDefaultResponseFeedbackInExplorationEditorPage(
- 'Wrong, try again!'
- );
- await curriculumAdmin.addHintToState(
- 'Remember that negative numbers are less than 0.'
- );
- await curriculumAdmin.addSolutionToState(
- '-99',
- 'The number -99 is a negative number.',
- true
- );
- await curriculumAdmin.saveExplorationDraft();
-
- // Navigate to the new card and add Study Guide content.
- await curriculumAdmin.navigateToCard('Last Card');
- await curriculumAdmin.updateCardContent(
- 'Congratulations! You have completed the exploration.'
- );
- await curriculumAdmin.addInteraction('End Exploration');
-
- // Save the draft.
- await curriculumAdmin.saveExplorationDraft();
- explorationId = await curriculumAdmin.publishExplorationWithMetadata(
- 'What is a Fraction?',
- 'Learn the basics of Fractions',
- 'Algebra'
- );
-
- await curriculumAdmin.createAndPublishTopic(
- 'Fractions',
- 'Basics Of Fractions',
- 'fractions'
- );
-
- await curriculumAdmin.createAndPublishClassroom(
- 'Math',
- 'math',
- 'Fractions'
- );
-
- await curriculumAdmin.createAndPublishStoryWithChapter(
- 'Fraction Story',
- 'fraction-story',
- 'What is a Fraction?',
- explorationId as string,
- 'Fractions'
- );
- },
- // Test takes longer than default timeout.
- 600000
- );
-
- it('should be able to change the site language to an RTL language', async function () {
- await loggedInUser1.changeSiteLanguage('ar');
-
- await loggedInUser1.page.waitForSelector('.mat-mdc-menu-panel', {
- hidden: true,
- });
-
- await loggedInUser1.navigateToLearnerDashboard();
-
- await loggedInUser1.verifyPageIsRTL();
-
- await loggedInUser1.expectScreenshotToMatch(
- 'RTLArabicLearnerDashboard',
- __dirname
- );
-
- await loggedInUser1.navigateToHome(false);
-
- await loggedInUser1.verifyPageIsRTL();
-
- await loggedInUser1.expectScreenshotToMatch('RTLArabicHomePage', __dirname);
- });
-
- it('should be able to visit about page', async function () {
- // Navigate to about page.
- await loggedInUser1.clickAboutButtonInAboutMenuOnNavbar();
- await loggedInUser1.verifyPageIsRTL();
- await loggedInUser1.expectScreenshotToMatch(
- 'RTLArabicAboutPage',
- __dirname
- );
- });
-
- it('should be able to play an exploration and interact with pop-ups, modals and buttons', async function () {
- // Navigate to community library.
- await loggedInUser1.navigateToCommunityLibraryPage();
- await loggedInUser1.verifyPageIsRTL();
-
- // Check lesson player.
- await loggedInUser1.searchForLessonInSearchBar('What is a Fraction?');
- await loggedInUser1.playLessonFromSearchResults('What is a Fraction?');
- await loggedInUser1.verifyPageIsRTL();
-
- // Check hints and lesson info are displayed in RTL.
- await loggedInUser1.continueToNextCard();
- await loggedInUser1.submitAnswer('1');
-
- await loggedInUser1.viewHint();
- await loggedInUser1.verifyPageIsRTL();
- await loggedInUser1.closeHintModal();
-
- await loggedInUser1.openLessonInfoModal();
- await loggedInUser1.verifyPageIsRTL();
- });
-
- afterAll(async function () {
- await UserFactory.closeAllBrowsers();
- });
-});
diff --git a/core/tests/puppeteer-acceptance-tests/utilities/common/rte-editor.ts b/core/tests/puppeteer-acceptance-tests/utilities/common/rte-editor.ts
index 3626bfcc8ea31..b76cedc361163 100644
--- a/core/tests/puppeteer-acceptance-tests/utilities/common/rte-editor.ts
+++ b/core/tests/puppeteer-acceptance-tests/utilities/common/rte-editor.ts
@@ -191,6 +191,28 @@ export class RTEEditor {
hidden: true,
});
}
+ /**
+ * Types a LaTeX expression into the Math RTE modal.
+ * @param {string} latex - The LaTeX expression to type.
+ */
+ async typeMathExpression(latex: string): Promise {
+ const textareaElement = await this.parentPage.$(
+ 'textarea[placeholder*="Enter a math expression using LaTeX"]'
+ );
+ if (!textareaElement) {
+ throw new Error('Math formula textarea not found.');
+ }
+ await textareaElement.type(latex);
+ // Press Tab to blur the textarea. This triggers Angular's ngModel change
+ // detection, which validates the LaTeX input and enables the "Done" button.
+ await this.parentPage.keyboard.press('Tab');
+
+ // Use the custom waitForNetworkIdle defined in BaseUser if possible,
+ // otherwise just wait for network idle using Puppeteer's native methods.
+ // wait for network idle is usually handled by the test utilities, but we
+ // will just use standard waitForNetworkIdle since this is a page object.
+ await this.parentPage.waitForNetworkIdle();
+ }
}
export const RTE_BUTTON_TITLES = {
diff --git a/core/tests/puppeteer-acceptance-tests/utilities/user/exploration-editor.ts b/core/tests/puppeteer-acceptance-tests/utilities/user/exploration-editor.ts
index 973b66b9cee4c..6e57de6b76083 100644
--- a/core/tests/puppeteer-acceptance-tests/utilities/user/exploration-editor.ts
+++ b/core/tests/puppeteer-acceptance-tests/utilities/user/exploration-editor.ts
@@ -28,6 +28,7 @@ import {GraphViz} from '../common/interactions/graph-viz';
import {PencilCode} from '../common/interactions/pencil-code';
import {ImageAreaSelection} from '../common/interactions/image-area-selection';
import {ExplorationEditorModal} from '../common/exploration-editor';
+import {RTEEditor, RTE_BUTTON_TITLES} from '../common/rte-editor';
const creatorDashboardPage = testConstants.URLs.CreatorDashboard;
const baseUrl = testConstants.URLs.BaseURL;
@@ -46,6 +47,8 @@ const closeResponseModalButton = '.e2e-test-close-add-response-modal';
const loadingFullPageOverlaySelector = '.oppia-loading-full-page';
const activeModalBackdropSelector = '.modal-backdrop, ngb-modal-window, .modal';
+const activeModalMathJaxSvgSelector = '.modal-dialog .MathJax_SVG svg';
+const activeModalMathJaxTextSelector = '.modal-dialog .MathJax_SVG text';
const settingsTabSelector = 'a.e2e-test-exploration-settings-tab';
const addTitleBar = 'input#explorationTitle';
@@ -7917,6 +7920,88 @@ export class ExplorationEditor extends BaseUser {
await this.expectElementToBeVisible(explorationFeedbackTabContentSelector);
}
+ /**
+ * Adds a math formula to the current card's content using the RTE toolbar.
+ * This opens the state content editor, inserts a math formula via the
+ * CKEditor math button, and saves the content.
+ * @param {string} latex - The LaTeX expression to insert.
+ * @param {string} [expectedText] - The text expected to be rendered inside the MathJax SVG text node.
+ */
+ async addMathFormulaToCardContent(
+ latex: string,
+ expectedText?: string
+ ): Promise {
+ await this.page.waitForSelector(stateEditSelector, {visible: true});
+ await this.clickOnElementWithSelector(stateEditSelector);
+ await this.clearAllTextFrom(stateContentInputField);
+
+ // Insert mathematical formula via the RTE toolbar.
+ const rteEditor = new RTEEditor(this.page, this.page);
+ await rteEditor.clickOnRTEOptionWithTitle(
+ RTE_BUTTON_TITLES.MATH_FORMULA.EN
+ );
+ await this.waitForNetworkIdle();
+ await rteEditor.typeMathExpression(latex);
+
+ if (expectedText) {
+ await this.expectMathJaxToRenderArabicTextInSvgTextNode(expectedText);
+ }
+
+ await this.clickOnElementWithSelector(closeButtonForExtraModel);
+ await this.waitForNetworkIdle();
+
+ await this.clickOnElementWithSelector(saveContentButton);
+ await this.page.waitForSelector(stateContentInputField, {hidden: true});
+ showMessage('Math formula added to card content successfully.');
+ }
+
+ /**
+ * Asserts that Arabic text in a MathJax-rendered formula is preserved as a
+ * single contiguous string inside a text element, rather than being
+ * split into disconnected SVG text nodes. This verifies that the
+ * mtextFontInherit configuration is working correctly (Fixes #26148).
+ * @param {string} expectedText - The Arabic text expected inside the
+ * text node.
+ */
+ async expectMathJaxToRenderArabicTextInSvgTextNode(
+ expectedText: string
+ ): Promise {
+ // Math interactions require heavy MathJax rendering and take significantly
+ // longer to load than other interactions.
+ await this.page.waitForSelector(activeModalMathJaxSvgSelector, {
+ timeout: 15000,
+ });
+
+ const {arabicTextContent, rawSvgHtml} = await this.page.evaluate(
+ (svgSelector, textSelector) => {
+ const svgElement = document.querySelector(svgSelector);
+ const textNodes = document.querySelectorAll(textSelector);
+ return {
+ arabicTextContent:
+ Array.from(textNodes)
+ .map(node => node.textContent?.trim() || '')
+ .filter(text => text !== '')
+ .join(' | ') || null,
+ rawSvgHtml: svgElement ? svgElement.textContent : null,
+ };
+ },
+ activeModalMathJaxSvgSelector,
+ activeModalMathJaxTextSelector
+ );
+
+ if (!arabicTextContent || !arabicTextContent.includes(expectedText)) {
+ throw new Error(
+ `Expected MathJax to render Arabic text "${expectedText}" inside an ` +
+ `SVG element, but found: "${arabicTextContent}". ` +
+ `Raw SVG HTML for debugging: \n${rawSvgHtml}\n ` +
+ 'This indicates that mtextFontInherit is not working correctly.'
+ );
+ }
+ showMessage(
+ 'Arabic text rendered correctly inside text node: ' + arabicTextContent
+ );
+ }
+
/**
* Expects explorations displayed in the grid to match the provided order.
* @param {string[]} expectedTitles - Ordered list of expected exploration titles.
diff --git a/core/tests/puppeteer-acceptance-tests/utilities/user/logged-out-user.ts b/core/tests/puppeteer-acceptance-tests/utilities/user/logged-out-user.ts
index 7ae6b4380ed21..2444eddf8b133 100644
--- a/core/tests/puppeteer-acceptance-tests/utilities/user/logged-out-user.ts
+++ b/core/tests/puppeteer-acceptance-tests/utilities/user/logged-out-user.ts
@@ -372,6 +372,7 @@ const closeLessonInfoButton = '.e2e-test-close-lesson-info-modal-button';
const resumeExplorationButton = '.resume-button';
const restartExplorationButton = '.restart-button';
const saveProgressButton = '.save-progress-btn';
+const saveProgressBtnTooltipSelector = '.save-progress-btn-tooltip';
const createAccountButton = '.create-account-btn';
const validityInfoTextSelector = '.guide-text';
const copyProgressUrlButton = '.oppia-uid-copy-btn';
@@ -5262,6 +5263,18 @@ export class LoggedOutUser extends BaseUser {
*/
async saveProgress(): Promise {
await this.page.waitForSelector(saveProgressButton, {visible: true});
+
+ // TODO(#26357): Remove this wait once the frontend race condition is fixed.
+ // The saveProgressBtnTooltipSelector div is rendered directly below the
+ // button in a flex column when checkpointStatusArray[0] === 'in-progress'.
+ // This happens when the modal opens before Angular's async checkpoint
+ // service has updated completedCheckpointsCount. While the tooltip is
+ // present, elementFromPoint at the button's center returns the tooltip div
+ // instead of the button, causing waitForElementToBeClickable to time out.
+ // We wait here until the tooltip disappears (i.e. completedCheckpointsCount
+ // has been updated to reflect the reached checkpoint).
+ await this.expectElementToBeVisible(saveProgressBtnTooltipSelector, false);
+
await this.clickOnElementWithSelector(saveProgressButton);
await this.page.waitForSelector(signInBoxInSaveProressModalSelector, {
diff --git a/scripts/linters/general_purpose_linter.py b/scripts/linters/general_purpose_linter.py
index 4d1129643a88a..ad20b1d6b5e8f 100644
--- a/scripts/linters/general_purpose_linter.py
+++ b/scripts/linters/general_purpose_linter.py
@@ -18,6 +18,7 @@
from __future__ import annotations
+import json
import os
import re
@@ -712,6 +713,95 @@ def check_extra_js_files(self) -> concurrent_task_utils.TaskResult:
name, failed, error_messages, error_messages
)
+ def check_modal_component_patterns(
+ self,
+ ) -> concurrent_task_utils.TaskResult:
+ """Checks that modal components follow the standardized pattern.
+
+ This ensures:
+ 1. Files using NgbActiveModal must also use MatBottomSheetRef to
+ provide a mobile-friendly bottom sheet view.
+ 2. Calls to ngbModal.open() must include backdrop: 'static'
+ to prevent closing the modal when clicking outside.
+ """
+ name = 'Modal component pattern'
+ error_messages: List[str] = []
+ failed = False
+
+ # Load allowlist once before the loop.
+ allowlist_path = os.path.join(
+ os.path.dirname(__file__), 'modal_allowlist.json'
+ )
+ with open(allowlist_path, 'r', encoding='utf-8') as f:
+ allowlist = json.load(f)
+
+ for filepath in self.all_filepaths:
+ if not filepath.endswith('.ts'):
+ continue
+ # Skip test/spec files since they mock modal behavior.
+ if filepath.endswith('.spec.ts'):
+ continue
+
+ if filepath in allowlist:
+ continue
+
+ file_content = self.file_cache.read(filepath)
+
+ # Remove comments to avoid false positives.
+ file_content_without_comments = re.sub(
+ r'//.*?\n|/\*.*?\*/', '', file_content, flags=re.DOTALL
+ )
+
+ # Check 1: Modal Components (inject NgbActiveModal)
+ if bool(
+ re.search(r'\bNgbActiveModal\b', file_content_without_comments)
+ ) and not bool(
+ re.search(
+ r'\bMatBottomSheetRef\b', file_content_without_comments
+ )
+ ):
+ failed = True
+ error_messages.append(
+ '%s --> Modal components using NgbActiveModal must also '
+ 'use MatBottomSheetRef to provide a mobile-friendly '
+ 'bottom sheet view.' % filepath
+ )
+
+ # Check 2: Opener Components (call ngbModal.open)
+ modal_open_matches = re.findall(
+ r'\bngbModal\.open\b', file_content_without_comments
+ )
+ if modal_open_matches:
+ if not bool(
+ re.search(
+ r'\bMatBottomSheet\b', file_content_without_comments
+ )
+ ):
+ failed = True
+ error_messages.append(
+ '%s --> Components opening modals with ngbModal.open '
+ 'must also use MatBottomSheet to support mobile '
+ 'views.' % filepath
+ )
+
+ # Check 3: Backdrop static.
+ num_static_backdrops = len(
+ re.findall(
+ r'backdrop\s*:\s*[\'"]static[\'"]',
+ file_content_without_comments,
+ )
+ )
+ if len(modal_open_matches) > num_static_backdrops:
+ failed = True
+ error_messages.append(
+ '%s --> ngbModal.open must be called with {backdrop: \'static\'} '
+ 'to prevent closing on outside clicks.' % filepath
+ )
+
+ return concurrent_task_utils.TaskResult(
+ name, failed, error_messages, error_messages
+ )
+
def check_rte_component_config_ids(
self,
) -> concurrent_task_utils.TaskResult:
@@ -812,6 +902,7 @@ def perform_all_lint_checks(self) -> List[concurrent_task_utils.TaskResult]:
self.check_extra_js_files(),
self.check_disallowed_flags(),
self.check_rte_component_config_ids(),
+ self.check_modal_component_patterns(),
]
return task_results
diff --git a/scripts/linters/general_purpose_linter_test.py b/scripts/linters/general_purpose_linter_test.py
index 797c9ec528de2..56dd37279f6c8 100644
--- a/scripts/linters/general_purpose_linter_test.py
+++ b/scripts/linters/general_purpose_linter_test.py
@@ -75,6 +75,22 @@
VALID_SERVICE_FILE_PATH = os.path.join(
LINTER_TESTS_DIR, 'valid-backend-api.service.ts'
)
+INVALID_MODAL_NGBACTIVEMODAL_FILEPATH: Final = os.path.join(
+ LINTER_TESTS_DIR, 'invalid_modal_component_ngbactivemodal.ts'
+)
+INVALID_MODAL_NGBMODAL_OPEN_FILEPATH: Final = os.path.join(
+ LINTER_TESTS_DIR, 'invalid_modal_component_ngbmodal_open.ts'
+)
+INVALID_MODAL_NO_BACKDROP_FILEPATH: Final = os.path.join(
+ LINTER_TESTS_DIR, 'invalid_modal_component_no_backdrop.ts'
+)
+INVALID_MODAL_MULTIPLE_OPENS_MISSING_BACKDROP_FILEPATH: Final = os.path.join(
+ LINTER_TESTS_DIR,
+ 'invalid_modal_component_multiple_opens_missing_backdrop.ts',
+)
+VALID_MODAL_COMPONENT_FILEPATH: Final = os.path.join(
+ LINTER_TESTS_DIR, 'valid_modal_component.ts'
+)
# PY filepaths.
INVALID_REQUEST_FILEPATH: Final = os.path.join(
@@ -692,3 +708,98 @@ def test_check_bad_patterns_in_excluded_dirs(self) -> None:
)
self.assertFalse(check_status)
self.assertEqual(error_messages, [])
+
+ def test_modal_component_missing_mat_bottom_sheet_ref(self) -> None:
+ linter = general_purpose_linter.GeneralPurposeLinter(
+ [INVALID_MODAL_NGBACTIVEMODAL_FILEPATH], FILE_CACHE
+ )
+ lint_task_report = linter.check_modal_component_patterns()
+ self.assert_same_list_elements(
+ [
+ '%s --> Modal components using NgbActiveModal must also '
+ 'use MatBottomSheetRef to provide a mobile-friendly '
+ 'bottom sheet view.' % INVALID_MODAL_NGBACTIVEMODAL_FILEPATH
+ ],
+ lint_task_report.trimmed_messages,
+ )
+ self.assertEqual('Modal component pattern', lint_task_report.name)
+ self.assertTrue(lint_task_report.failed)
+
+ def test_modal_component_missing_mat_bottom_sheet(self) -> None:
+ linter = general_purpose_linter.GeneralPurposeLinter(
+ [INVALID_MODAL_NGBMODAL_OPEN_FILEPATH], FILE_CACHE
+ )
+ lint_task_report = linter.check_modal_component_patterns()
+ self.assert_same_list_elements(
+ [
+ '%s --> Components opening modals with ngbModal.open '
+ 'must also use MatBottomSheet to support mobile '
+ 'views.' % INVALID_MODAL_NGBMODAL_OPEN_FILEPATH
+ ],
+ lint_task_report.trimmed_messages,
+ )
+ self.assertEqual('Modal component pattern', lint_task_report.name)
+ self.assertTrue(lint_task_report.failed)
+
+ def test_modal_component_missing_backdrop_static(self) -> None:
+ linter = general_purpose_linter.GeneralPurposeLinter(
+ [INVALID_MODAL_NO_BACKDROP_FILEPATH], FILE_CACHE
+ )
+ lint_task_report = linter.check_modal_component_patterns()
+ self.assert_same_list_elements(
+ [
+ '%s --> ngbModal.open must be called with {backdrop: \'static\'} '
+ 'to prevent closing on outside clicks.'
+ % INVALID_MODAL_NO_BACKDROP_FILEPATH
+ ],
+ lint_task_report.trimmed_messages,
+ )
+ self.assertEqual('Modal component pattern', lint_task_report.name)
+ self.assertTrue(lint_task_report.failed)
+
+ def test_modal_component_multiple_opens_missing_backdrop(self) -> None:
+ linter = general_purpose_linter.GeneralPurposeLinter(
+ [INVALID_MODAL_MULTIPLE_OPENS_MISSING_BACKDROP_FILEPATH], FILE_CACHE
+ )
+ lint_task_report = linter.check_modal_component_patterns()
+ self.assert_same_list_elements(
+ [
+ '%s --> ngbModal.open must be called with {backdrop: \'static\'} '
+ 'to prevent closing on outside clicks.'
+ % INVALID_MODAL_MULTIPLE_OPENS_MISSING_BACKDROP_FILEPATH
+ ],
+ lint_task_report.trimmed_messages,
+ )
+ self.assertEqual('Modal component pattern', lint_task_report.name)
+ self.assertTrue(lint_task_report.failed)
+
+ def test_valid_modal_component_passes(self) -> None:
+ linter = general_purpose_linter.GeneralPurposeLinter(
+ [VALID_MODAL_COMPONENT_FILEPATH], FILE_CACHE
+ )
+ lint_task_report = linter.check_modal_component_patterns()
+ self.assertEqual(lint_task_report.trimmed_messages, [])
+ self.assertEqual('Modal component pattern', lint_task_report.name)
+ self.assertFalse(lint_task_report.failed)
+
+ def test_spec_files_are_skipped(self) -> None:
+ linter = general_purpose_linter.GeneralPurposeLinter(
+ ['scripts/linters/test_files/valid_modal_component.spec.ts'],
+ FILE_CACHE,
+ )
+ lint_task_report = linter.check_modal_component_patterns()
+ self.assertEqual(lint_task_report.trimmed_messages, [])
+ self.assertEqual('Modal component pattern', lint_task_report.name)
+ self.assertFalse(lint_task_report.failed)
+
+ def test_allowlisted_files_are_skipped(self) -> None:
+ allowlisted_file = (
+ 'core/templates/base-components/oppia-footer.component.ts'
+ )
+ linter = general_purpose_linter.GeneralPurposeLinter(
+ [allowlisted_file], FILE_CACHE
+ )
+ lint_task_report = linter.check_modal_component_patterns()
+ self.assertEqual(lint_task_report.trimmed_messages, [])
+ self.assertEqual('Modal component pattern', lint_task_report.name)
+ self.assertFalse(lint_task_report.failed)
diff --git a/scripts/linters/modal_allowlist.json b/scripts/linters/modal_allowlist.json
new file mode 100644
index 0000000000000..2e27c5714f761
--- /dev/null
+++ b/scripts/linters/modal_allowlist.json
@@ -0,0 +1,250 @@
+[
+ "core/templates/base-components/feedback-modal.component.ts",
+ "core/templates/base-components/oppia-footer.component.ts",
+ "core/templates/base-components/thanks-for-subscribing-modal.component.ts",
+ "core/templates/components/button-directives/exploration-embed-button-modal.component.ts",
+ "core/templates/components/certificate-assessment-offering-helper/certificate-offering-confirmation-modal.component.ts",
+ "core/templates/components/certificate-assessment-offering-helper/delete-certificate-offering-modal.component.ts",
+ "core/templates/components/certificate-assessment-offering-helper/post-certificate-offering-result-modal.component.ts",
+ "core/templates/components/common-layout-directives/common-elements/answer-content-modal.component.ts",
+ "core/templates/components/common-layout-directives/common-elements/confirm-or-cancel-modal.component.ts",
+ "core/templates/components/common-layout-directives/navigation-bars/top-navigation-bar.component.ts",
+ "core/templates/components/entity-creation-services/story-creation-backend-api.service.ts",
+ "core/templates/components/entity-creation-services/topic-creation.service.ts",
+ "core/templates/components/forms/custom-forms-directives/edit-thumbnail-modal.component.ts",
+ "core/templates/components/forms/custom-forms-directives/image-uploader-modal.component.ts",
+ "core/templates/components/forms/custom-forms-directives/image-uploader.component.ts",
+ "core/templates/components/forms/custom-forms-directives/thumbnail-uploader.component.ts",
+ "core/templates/components/forms/forms-templates/mark-audio-as-needing-update-modal.component.ts",
+ "core/templates/components/forms/forms-templates/mark-translations-as-needing-update-modal.component.ts",
+ "core/templates/components/keyboard-shortcut-help/keyboard-shortcut-help-modal.component.ts",
+ "core/templates/components/question-directives/modal-templates/confirm-question-exit-modal.component.ts",
+ "core/templates/components/question-directives/modal-templates/question-editor-save-modal.component.ts",
+ "core/templates/components/question-directives/modal-templates/remove-question-skill-link-modal.component.ts",
+ "core/templates/components/question-directives/question-misconception-editor/question-misconception-editor.component.ts",
+ "core/templates/components/question-directives/question-misconception-editor/tag-misconception-modal-component.ts",
+ "core/templates/components/question-directives/question-player/question-player-concept-card-modal.component.ts",
+ "core/templates/components/question-directives/question-player/question-player.component.ts",
+ "core/templates/components/question-directives/question-player/skill-mastery-modal.component.ts",
+ "core/templates/components/question-directives/questions-list/questions-list.component.ts",
+ "core/templates/components/save-pending-changes/save-pending-changes-modal.component.ts",
+ "core/templates/components/skill-selector/merge-skill-modal.component.ts",
+ "core/templates/components/skill-selector/select-skill-modal.component.ts",
+ "core/templates/components/stale-tab-info/stale-tab-info-modal.component.ts",
+ "core/templates/components/state-directives/outcome-editor/outcome-editor.component.ts",
+ "core/templates/components/state-editor/state-interaction-editor/state-interaction-editor.component.ts",
+ "core/templates/components/state-editor/state-responses-editor/state-responses.component.ts",
+ "core/templates/components/state-editor/state-skill-editor/state-skill-editor.component.ts",
+ "core/templates/components/translation-suggestion-page/confirm-translation-exit-modal/confirm-translation-exit-modal.component.ts",
+ "core/templates/components/version-diff-visualization/version-diff-visualization.component.ts",
+ "core/templates/domain/learner_dashboard/learner-dashboard-activity-backend-api.service.ts",
+ "core/templates/pages/about-page/about-page.component.ts",
+ "core/templates/pages/admin-page/roles-tab/topic-manager-role-editor-modal.component.ts",
+ "core/templates/pages/admin-page/roles-tab/translation-coordinator-role-editor-modal.component.ts",
+ "core/templates/pages/blog-dashboard-page/blog-dashboard-page.component.ts",
+ "core/templates/pages/blog-dashboard-page/blog-post-action-confirmation/blog-post-action-confirmation.component.ts",
+ "core/templates/pages/blog-dashboard-page/blog-post-editor/blog-post-editor.component.ts",
+ "core/templates/pages/blog-dashboard-page/modal-templates/author-detail-editor-modal.component.ts",
+ "core/templates/pages/blog-dashboard-page/modal-templates/blog-card-preview-modal.component.ts",
+ "core/templates/pages/blog-dashboard-page/modal-templates/upload-blog-post-thumbnail-modal.component.ts",
+ "core/templates/pages/classroom-admin-page/classroom-admin-page.component.ts",
+ "core/templates/pages/classroom-admin-page/modals/classroom-editor-confirm-modal.component.ts",
+ "core/templates/pages/classroom-admin-page/modals/create-new-classroom-modal.component.ts",
+ "core/templates/pages/classroom-admin-page/modals/delete-classroom-confirm-modal.component.ts",
+ "core/templates/pages/classroom-admin-page/modals/delete-topic-from-classroom-modal.component.ts",
+ "core/templates/pages/classroom-admin-page/modals/topic-dependency-graph-viz-modal.component.ts",
+ "core/templates/pages/classroom-admin-page/modals/update-classrooms-order-modal.component.ts",
+ "core/templates/pages/collection-editor-page/modals/collection-editor-pre-publish-modal.component.ts",
+ "core/templates/pages/collection-editor-page/modals/collection-editor-save-modal.component.ts",
+ "core/templates/pages/collection-editor-page/navbar/collection-editor-navbar.component.ts",
+ "core/templates/pages/contributor-dashboard-admin-page/question-role-editor-modal/cd-admin-question-role-editor-modal.component.ts",
+ "core/templates/pages/contributor-dashboard-admin-page/translation-role-editor-modal/cd-admin-translation-role-editor-modal.component.ts",
+ "core/templates/pages/contributor-dashboard-admin-page/username-input-modal/username-input-modal.component.ts",
+ "core/templates/pages/contributor-dashboard-page/contributions-and-review/contributions-and-review.component.ts",
+ "core/templates/pages/contributor-dashboard-page/modal-templates/certificate-download-modal.component.ts",
+ "core/templates/pages/contributor-dashboard-page/modal-templates/login-required-modal.component.ts",
+ "core/templates/pages/contributor-dashboard-page/modal-templates/question-suggestion-editor-modal.component.ts",
+ "core/templates/pages/contributor-dashboard-page/modal-templates/question-suggestion-review-modal.component.ts",
+ "core/templates/pages/contributor-dashboard-page/modal-templates/translation-modal.component.ts",
+ "core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts",
+ "core/templates/pages/contributor-dashboard-page/question-opportunities/question-opportunities.component.ts",
+ "core/templates/pages/create-certificate-offering-page/create-certificate-offering-page.component.ts",
+ "core/templates/pages/creator-dashboard-page/modal-templates/create-activity-modal.component.ts",
+ "core/templates/pages/creator-dashboard-page/modal-templates/upload-activity-modal.component.ts",
+ "core/templates/pages/delete-account-page/delete-account-page.component.ts",
+ "core/templates/pages/delete-account-page/templates/delete-account-modal.component.ts",
+ "core/templates/pages/donate-page/donate-page.component.ts",
+ "core/templates/pages/donate-page/donation-box/donation-box-modal.component.ts",
+ "core/templates/pages/donate-page/thanks-for-donating-modal.component.ts",
+ "core/templates/pages/edit-certificate-offering-page/edit-certificate-offering-page.component.ts",
+ "core/templates/pages/exploration-editor-page/editor-tab/graph-directives/exploration-graph.component.ts",
+ "core/templates/pages/exploration-editor-page/editor-tab/state-version-history/state-version-history.component.ts",
+ "core/templates/pages/exploration-editor-page/editor-tab/templates/modal-templates/add-answer-group-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/editor-tab/templates/modal-templates/add-hint-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/editor-tab/templates/modal-templates/add-or-update-solution-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/editor-tab/templates/modal-templates/add-outcome-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/editor-tab/templates/modal-templates/confirm-delete-state-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/editor-tab/templates/modal-templates/customize-interaction-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/editor-tab/templates/modal-templates/delete-answer-group-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/editor-tab/templates/modal-templates/delete-hint-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/editor-tab/templates/modal-templates/delete-interaction-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/editor-tab/templates/modal-templates/delete-last-hint-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/editor-tab/templates/modal-templates/delete-solution-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/editor-tab/templates/modal-templates/delete-state-skill-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/editor-tab/templates/modal-templates/exploration-graph-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/editor-tab/templates/modal-templates/teach-oppia-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/editor-tab/training-panel/training-data-editor-panel-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/editor-tab/training-panel/training-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/editor-tab/training-panel/training-modal.service.ts",
+ "core/templates/pages/exploration-editor-page/feedback-tab/templates/create-feedback-thread-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/history-tab/history-tab.component.ts",
+ "core/templates/pages/exploration-editor-page/history-tab/modal-templates/check-revert-exploration-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/history-tab/modal-templates/revert-exploration-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/modal-templates/confirm-discard-changes-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/modal-templates/confirm-leave-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/modal-templates/editor-reloading-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/modal-templates/exploration-metadata-diff-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/modal-templates/exploration-metadata-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/modal-templates/exploration-modify-translations-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/modal-templates/exploration-publish-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/modal-templates/exploration-save-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/modal-templates/exploration-save-prompt-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/modal-templates/help-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/modal-templates/lost-changes-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/modal-templates/metadata-version-history-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/modal-templates/post-publish-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/modal-templates/save-validation-fail-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/modal-templates/save-version-mismatch-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/modal-templates/state-diff-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/modal-templates/state-version-history-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/modal-templates/welcome-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/preview-tab/preview-tab.component.ts",
+ "core/templates/pages/exploration-editor-page/preview-tab/templates/preview-set-parameters-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/services/autosave-info-modals.service.ts",
+ "core/templates/pages/exploration-editor-page/services/exploration-save.service.ts",
+ "core/templates/pages/exploration-editor-page/services/exploration-states.service.ts",
+ "core/templates/pages/exploration-editor-page/settings-tab/settings-tab.component.ts",
+ "core/templates/pages/exploration-editor-page/settings-tab/templates/delete-exploration-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/settings-tab/templates/moderator-unpublish-exploration-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/settings-tab/templates/preview-summary-tile-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/settings-tab/templates/reassign-role-confirmation-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/settings-tab/templates/remove-role-confirmation-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/settings-tab/templates/transfer-exploration-ownership-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/statistics-tab/statistics-tab.component.ts",
+ "core/templates/pages/exploration-editor-page/statistics-tab/templates/state-stats-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/translation-tab/modal-templates/add-audio-translation-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/translation-tab/modal-templates/delete-audio-translation-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/translation-tab/modal-templates/translation-tab-busy-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/translation-tab/modal-templates/welcome-translation-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/translation-tab/voiceover-card/modals/automatic-voiceover-regeneration-confirm-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/translation-tab/voiceover-card/modals/voiceover-removal-confirm-modal.component.ts",
+ "core/templates/pages/exploration-editor-page/translation-tab/voiceover-card/voiceover-card.component.ts",
+ "core/templates/pages/exploration-player-page/current-lesson-player/layout-directives/content-language-selector.component.ts",
+ "core/templates/pages/exploration-player-page/current-lesson-player/layout-directives/exploration-footer.component.ts",
+ "core/templates/pages/exploration-player-page/current-lesson-player/layout-directives/learner-local-nav.component.ts",
+ "core/templates/pages/exploration-player-page/current-lesson-player/modals/display-hint-modal.component.ts",
+ "core/templates/pages/exploration-player-page/current-lesson-player/modals/display-solution-interstitial-modal.component.ts",
+ "core/templates/pages/exploration-player-page/current-lesson-player/modals/display-solution-modal.component.ts",
+ "core/templates/pages/exploration-player-page/current-lesson-player/modals/exploration-successfully-flagged-modal.component.ts",
+ "core/templates/pages/exploration-player-page/current-lesson-player/modals/flag-exploration-modal.component.ts",
+ "core/templates/pages/exploration-player-page/current-lesson-player/modals/refresher-exploration-confirmation-modal.component.ts",
+ "core/templates/pages/exploration-player-page/current-lesson-player/modals/switch-content-language-refresh-required-modal.component.ts",
+ "core/templates/pages/exploration-player-page/current-lesson-player/templates/lesson-information-card-modal.component.ts",
+ "core/templates/pages/exploration-player-page/current-lesson-player/templates/progress-reminder-modal.component.ts",
+ "core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/new-ratings-and-recommendations.component.ts",
+ "core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/new-switch-content-language-refresh-required-modal.component.ts",
+ "core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/take-break-modal.component.ts",
+ "core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/lesson-player-footer/new-progress-reminder-modal.component.ts",
+ "core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/lesson-player-footer/progress-tracker.component.ts",
+ "core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/lesson-player-footer/save-progress-modal.component.ts",
+ "core/templates/pages/exploration-player-page/new-lesson-player/lesson-player-page.component.ts",
+ "core/templates/pages/exploration-player-page/services/concept-card-manager.service.ts",
+ "core/templates/pages/exploration-player-page/services/refresher-exploration-confirmation-modal.service.ts",
+ "core/templates/pages/learner-dashboard-page/learner-dashboard-icons.component.ts",
+ "core/templates/pages/learner-dashboard-page/learner-groups-tab.component.ts",
+ "core/templates/pages/learner-dashboard-page/modal-templates/decline-invitaiton-modal.component.ts",
+ "core/templates/pages/learner-dashboard-page/modal-templates/learner-playlist-modal.component.ts",
+ "core/templates/pages/learner-dashboard-page/modal-templates/remove-activity-modal.component.ts",
+ "core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-details-modal.component.ts",
+ "core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-invitation-modal.component.ts",
+ "core/templates/pages/learner-dashboard-page/suggestion-modal/learner-dashboard-suggestion-modal.component.ts",
+ "core/templates/pages/learner-dashboard-page/suggestion-modal/suggestion-modal-for-learner-dashboard.service.ts",
+ "core/templates/pages/learner-group-pages/edit-group/learner-group-preferences.component.ts",
+ "core/templates/pages/learner-group-pages/edit-group/learner-group-syllabus.component.ts",
+ "core/templates/pages/learner-group-pages/templates/delete-learner-group-modal.component.ts",
+ "core/templates/pages/learner-group-pages/templates/exit-learner-group-modal.component.ts",
+ "core/templates/pages/learner-group-pages/templates/invite-learners-modal.component.ts",
+ "core/templates/pages/learner-group-pages/templates/invite-successful-modal.component.ts",
+ "core/templates/pages/learner-group-pages/templates/learner-group-preferences-modal.component.ts",
+ "core/templates/pages/learner-group-pages/templates/remove-item-modal.component.ts",
+ "core/templates/pages/learner-group-pages/templates/syllabus-addition-success-modal.component.ts",
+ "core/templates/pages/learner-group-pages/view-group/view-learner-group-page.component.ts",
+ "core/templates/pages/preferences-page/modal-templates/edit-profile-picture-modal.component.ts",
+ "core/templates/pages/preferences-page/preferences-page.component.ts",
+ "core/templates/pages/release-coordinator-page/modals/delete-user-group-confirm-modal.component.ts",
+ "core/templates/pages/release-coordinator-page/release-coordinator-page.component.ts",
+ "core/templates/pages/signup-page/modals/license-explanation-modal.component.ts",
+ "core/templates/pages/signup-page/modals/registration-session-expired-modal.component.ts",
+ "core/templates/pages/signup-page/signup-page.component.ts",
+ "core/templates/pages/skill-editor-page/editor-tab/skill-concept-card-editor/skill-concept-card-editor.component.ts",
+ "core/templates/pages/skill-editor-page/editor-tab/skill-editor-main-tab.component.ts",
+ "core/templates/pages/skill-editor-page/editor-tab/skill-misconceptions-editor/skill-misconceptions-editor.component.ts",
+ "core/templates/pages/skill-editor-page/editor-tab/skill-prerequisite-skills-editor/skill-prerequisite-skills-editor.component.ts",
+ "core/templates/pages/skill-editor-page/editor-tab/skill-preview-modal.component.ts",
+ "core/templates/pages/skill-editor-page/modal-templates/add-misconception-modal.component.ts",
+ "core/templates/pages/skill-editor-page/modal-templates/delete-misconception-modal.component.ts",
+ "core/templates/pages/skill-editor-page/modal-templates/skill-editor-save-modal.component.ts",
+ "core/templates/pages/skill-editor-page/navbar/skill-editor-navbar.component.ts",
+ "core/templates/pages/skill-editor-page/services/skill-editor-staleness-detection.service.ts",
+ "core/templates/pages/skill-editor-page/skill-editor-page.component.ts",
+ "core/templates/pages/story-editor-page/editor-tab/story-editor.component.ts",
+ "core/templates/pages/story-editor-page/editor-tab/story-node-editor.component.ts",
+ "core/templates/pages/story-editor-page/modal-templates/delete-chapter-modal.component.ts",
+ "core/templates/pages/story-editor-page/modal-templates/draft-chapter-confirmation-modal.component.ts",
+ "core/templates/pages/story-editor-page/modal-templates/new-chapter-title-modal.component.ts",
+ "core/templates/pages/story-editor-page/modal-templates/story-editor-save-modal.component.ts",
+ "core/templates/pages/story-editor-page/modal-templates/story-editor-unpublish-modal.component.ts",
+ "core/templates/pages/story-editor-page/navbar/story-editor-navbar-breadcrumb.component.ts",
+ "core/templates/pages/story-editor-page/navbar/story-editor-navbar.component.ts",
+ "core/templates/pages/story-editor-page/services/story-editor-staleness-detection.service.ts",
+ "core/templates/pages/story-editor-page/story-editor-page.component.ts",
+ "core/templates/pages/topic-editor-page/editor-tab/topic-editor-stories-list.component.ts",
+ "core/templates/pages/topic-editor-page/editor-tab/topic-editor-tab.component.ts",
+ "core/templates/pages/topic-editor-page/modal-templates/change-subtopic-assignment-modal.component.ts",
+ "core/templates/pages/topic-editor-page/modal-templates/create-new-story-modal.component.ts",
+ "core/templates/pages/topic-editor-page/modal-templates/create-new-subtopic-modal.component.ts",
+ "core/templates/pages/topic-editor-page/modal-templates/delete-story-modal.component.ts",
+ "core/templates/pages/topic-editor-page/modal-templates/questions-list-select-skill-and-difficulty-modal.component.ts",
+ "core/templates/pages/topic-editor-page/modal-templates/questions-opportunities-select-difficulty-modal.component.ts",
+ "core/templates/pages/topic-editor-page/modal-templates/rearrange-skills-in-subtopics-modal.component.ts",
+ "core/templates/pages/topic-editor-page/modal-templates/topic-editor-save-modal.component.ts",
+ "core/templates/pages/topic-editor-page/modal-templates/topic-editor-send-mail-modal.component.ts",
+ "core/templates/pages/topic-editor-page/navbar/topic-editor-navbar.component.ts",
+ "core/templates/pages/topic-editor-page/services/create-new-skill-modal.service.ts",
+ "core/templates/pages/topic-editor-page/subtopic-editor/add-study-guide-section.component.ts",
+ "core/templates/pages/topic-editor-page/subtopic-editor/delete-study-guide-section-modal.component.ts",
+ "core/templates/pages/topic-editor-page/topic-editor-page.component.ts",
+ "core/templates/pages/topic-editor-page/topic-editor-page.module.ts",
+ "core/templates/pages/topic-viewer-page/deprecations/modals/practice-session-confirmation-modal.component.ts",
+ "core/templates/pages/topics-and-skills-dashboard-page/modals/assign-skill-to-topic-modal.component.ts",
+ "core/templates/pages/topics-and-skills-dashboard-page/modals/create-new-skill-modal.component.ts",
+ "core/templates/pages/topics-and-skills-dashboard-page/modals/create-new-topic-modal.component.ts",
+ "core/templates/pages/topics-and-skills-dashboard-page/modals/delete-skill-modal.component.ts",
+ "core/templates/pages/topics-and-skills-dashboard-page/modals/delete-topic-modal.component.ts",
+ "core/templates/pages/topics-and-skills-dashboard-page/modals/unassign-skill-from-topics-modal.component.ts",
+ "core/templates/pages/topics-and-skills-dashboard-page/skills-list/skills-list.component.ts",
+ "core/templates/pages/topics-and-skills-dashboard-page/topics-list/topics-list.component.ts",
+ "core/templates/pages/voiceover-admin-page/modals/autogenerated-voiceover-run-info-modal.component.ts",
+ "core/templates/pages/voiceover-admin-page/modals/edit-voiceover-regeneration-support-modal.component.ts",
+ "core/templates/pages/voiceover-admin-page/modals/language-accent-removal-confirm-modal.component.ts",
+ "core/templates/pages/voiceover-admin-page/voiceover-admin-page.component.ts",
+ "core/templates/services/keyboard-shortcut.service.ts",
+ "core/templates/services/rte-helper-modal.component.ts",
+ "core/templates/services/suggestion-modal.service.ts",
+ "extensions/interactions/NumberWithUnits/directives/oppia-help-modal-number-with-units.component.ts",
+ "extensions/interactions/PencilCodeEditor/directives/pencil-code-reset-confirmation.component.ts",
+ "extensions/objects/templates/image-with-regions-reset-confirmation.component.ts",
+ "extensions/rich_text_components/Skillreview/directives/oppia-noninteractive-skillreview-concept-card-modal.component.ts",
+ "extensions/rich_text_components/Skillreview/directives/oppia-noninteractive-skillreview.component.ts",
+ "extensions/visualizations/oppia-visualization-sorted-tiles.component.ts"
+]
diff --git a/scripts/linters/test_files/invalid_modal_component_multiple_opens_missing_backdrop.ts b/scripts/linters/test_files/invalid_modal_component_multiple_opens_missing_backdrop.ts
new file mode 100644
index 0000000000000..f3cea4de4ed0d
--- /dev/null
+++ b/scripts/linters/test_files/invalid_modal_component_multiple_opens_missing_backdrop.ts
@@ -0,0 +1,46 @@
+// Copyright 2026 The Oppia Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS-IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/**
+ * @fileoverview Invalid test file with multiple modals missing backdrop.
+ */
+
+import { Component } from '@angular/core';
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
+import { MatBottomSheet } from '@angular/material/bottom-sheet';
+
+@Component({
+ selector: 'mock-component',
+ template: ''
+})
+export class MockComponent {
+ constructor(
+ private ngbModal: NgbModal,
+ private matBottomSheet: MatBottomSheet
+ ) {}
+
+ openFirstModal(): void {
+ // Has static backdrop
+ this.ngbModal.open(MockComponent, {
+ backdrop: 'static'
+ });
+ }
+
+ openSecondModal(): void {
+ // Missing static backdrop!
+ this.ngbModal.open(MockComponent, {
+ backdrop: true
+ });
+ }
+}
diff --git a/scripts/linters/test_files/invalid_modal_component_ngbactivemodal.ts b/scripts/linters/test_files/invalid_modal_component_ngbactivemodal.ts
new file mode 100644
index 0000000000000..97e6a886d9260
--- /dev/null
+++ b/scripts/linters/test_files/invalid_modal_component_ngbactivemodal.ts
@@ -0,0 +1,25 @@
+// Copyright 2026 The Oppia Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS-IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/**
+ * @fileoverview Invalid modal component test file.
+ */
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+
+export class InvalidModal {
+ constructor(
+ private activeModal: NgbActiveModal
+ ) {}
+}
diff --git a/scripts/linters/test_files/invalid_modal_component_ngbmodal_open.ts b/scripts/linters/test_files/invalid_modal_component_ngbmodal_open.ts
new file mode 100644
index 0000000000000..3edce295b0fc5
--- /dev/null
+++ b/scripts/linters/test_files/invalid_modal_component_ngbmodal_open.ts
@@ -0,0 +1,29 @@
+// Copyright 2026 The Oppia Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS-IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/**
+ * @fileoverview Invalid modal component test.
+ */
+
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
+
+export class InvalidModal {
+ constructor(
+ private ngbModal: NgbModal
+ ) {}
+
+ open() {
+ this.ngbModal.open(null, {backdrop: 'static'});
+ }
+}
diff --git a/scripts/linters/test_files/invalid_modal_component_no_backdrop.ts b/scripts/linters/test_files/invalid_modal_component_no_backdrop.ts
new file mode 100644
index 0000000000000..fab750fac40b9
--- /dev/null
+++ b/scripts/linters/test_files/invalid_modal_component_no_backdrop.ts
@@ -0,0 +1,31 @@
+// Copyright 2026 The Oppia Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS-IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/**
+ * @fileoverview Invalid modal component test.
+ */
+
+import { MatBottomSheet } from '@angular/material/bottom-sheet';
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
+
+export class InvalidModal {
+ constructor(
+ private ngbModal: NgbModal,
+ private bottomSheet: MatBottomSheet
+ ) {}
+
+ open() {
+ this.ngbModal.open(null);
+ }
+}
diff --git a/scripts/linters/test_files/valid_modal_component.ts b/scripts/linters/test_files/valid_modal_component.ts
new file mode 100644
index 0000000000000..a199bab8da29b
--- /dev/null
+++ b/scripts/linters/test_files/valid_modal_component.ts
@@ -0,0 +1,35 @@
+// Copyright 2026 The Oppia Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS-IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/**
+ * @fileoverview Valid modal component test file.
+ */
+
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { MatBottomSheetRef } from '@angular/material/bottom-sheet';
+import { MatBottomSheet } from '@angular/material/bottom-sheet';
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
+
+export class ValidModal {
+ constructor(
+ private activeModal: NgbActiveModal,
+ private bottomSheetRef: MatBottomSheetRef,
+ private ngbModal: NgbModal,
+ private bottomSheet: MatBottomSheet
+ ) {}
+
+ open() {
+ this.ngbModal.open(null, {backdrop: 'static'});
+ }
+}