diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/compose/AssignmentsE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/compose/AssignmentsE2ETest.kt index 2e8a8b72ba..b56fad8954 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/compose/AssignmentsE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/compose/AssignmentsE2ETest.kt @@ -76,7 +76,7 @@ class AssignmentsE2ETest: StudentComposeTest() { @E2E @Test @TestMetaData(Priority.IMPORTANT, FeatureCategory.SUBMISSIONS, TestCategory.E2E) - fun commentsBelongToSubmissionAttempts() { + fun testCommentsBelongToSubmissionAttempts() { Log.d(PREPARATION_TAG, "Seeding data.") val data = seedData(teachers = 1, courses = 1, students = 1) @@ -1004,6 +1004,78 @@ class AssignmentsE2ETest: StudentComposeTest() { submissionDetailsPage.assertSelectedAttempt("Attempt 1") } + @E2E + @Test + @TestMetaData(Priority.COMMON, FeatureCategory.ASSIGNMENTS, TestCategory.E2E) + fun testDraftAssignmentE2E() { + + Log.d(PREPARATION_TAG, "Seeding data.") + val data = seedData(teachers = 1, courses = 1, students = 1) + val student = data.studentsList[0] + val teacher = data.teachersList[0] + val course = data.coursesList[0] + + Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for '${course.name}' course.") + val pointsTextAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) + + Log.d(STEP_TAG, "Login with user: '${student.name}', login id: '${student.loginId}'.") + tokenLogin(student) + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Select course: '${course.name}'.") + dashboardPage.selectCourse(course) + + Log.d(STEP_TAG, "Navigate to course Assignments Page.") + courseBrowserPage.selectAssignments() + + Log.d(ASSERTION_TAG, "Assert that our assignments are present," + + "along with any grade/date info.") + assignmentListPage.assertHasAssignment(pointsTextAssignment) + + Log.d(STEP_TAG, "Click on assignment '${pointsTextAssignment.name}'.") + assignmentListPage.clickAssignment(pointsTextAssignment) + + Log.d(ASSERTION_TAG, "Assert that 'Submission & Rubric' label is displayed and navigate to Submission Details Page.") + assignmentDetailsPage.assertSubmissionAndRubricLabel() + + Log.d(STEP_TAG, "Click on the 'Submit Assignment' button.") + assignmentDetailsPage.clickSubmit() + + Log.d(STEP_TAG," Type some text into the submission text input and click on the back button to trigger the 'Save Draft' dialog, then click on the 'Don't Save' button on the 'Save Draft' pop-up dialog to not save the draft submission.") + val draftText = "Draft submission text" + textSubmissionUploadPage.typeText(draftText) + textSubmissionUploadPage.clickToolbarBackButton() + textSubmissionUploadPage.clickDontSaveDraft() + + Log.d(ASSERTION_TAG, "Assert that 'Submission & Rubric' label is displayed and navigate to Submission Details Page.") + assignmentDetailsPage.assertSubmissionAndRubricLabel() + + Log.d(STEP_TAG, "Click on the 'Submit Assignment' button.") + assignmentDetailsPage.clickSubmit() + + Log.d(STEP_TAG," Type some text into the submission text input and click on the back button to trigger the 'Save Draft' dialog, then click on the 'Save' button on the 'Save Draft' pop-up dialog to make a draft submission.") + textSubmissionUploadPage.typeText(draftText) + textSubmissionUploadPage.clickToolbarBackButton() + textSubmissionUploadPage.clickSaveDraft() + + Log.d(ASSERTION_TAG, "Assert that the Draft submission info (title, subtitle) are displayed on the Assignment Details Page since we saved a draft.") + assignmentDetailsPage.assertDraftAvailableInformation() + + Log.d(STEP_TAG, "Click on the 'Draft Available' link to open the saved draft assignment.") + assignmentDetailsPage.clickDraftSubmission() + + Log.d(ASSERTION_TAG, "Assert that the previously saved text ($draftText) is displayed in the text submission input.") + textSubmissionUploadPage.assertTextSubmissionDisplayed(draftText) + + Log.d(STEP_TAG, "Click on the 'Submit' button to submit the draft assignment.") + textSubmissionUploadPage.clickOnSubmitButton() + triggerWorkManagerJobs("SubmissionWorker") + + Log.d(ASSERTION_TAG, "Assert that the assignment's status is submitted and the 'Successfully submitted!' label is displayed.") + assignmentDetailsPage.assertStatusSubmitted() + assignmentDetailsPage.assertAssignmentSubmitted() + } + @E2E @Test @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.E2E) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/classic/TextSubmissionUploadPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/classic/TextSubmissionUploadPage.kt deleted file mode 100644 index c5095fca40..0000000000 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/classic/TextSubmissionUploadPage.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright (C) 2023 - present Instructure, Inc. - * - * 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. - */ -package com.instructure.student.ui.pages.classic - -import androidx.test.espresso.action.ViewActions.typeTextIntoFocusedView -import com.instructure.canvas.espresso.explicitClick -import com.instructure.espresso.OnViewWithId -import com.instructure.espresso.OnViewWithText -import com.instructure.espresso.click -import com.instructure.espresso.page.BasePage -import com.instructure.student.R - -class TextSubmissionUploadPage : BasePage(R.id.textSubmissionUpload) { - - val submitButton by OnViewWithId(R.id.menuSubmit) - val contentRceView by OnViewWithId(R.id.rce_webView) - val textEntryLabel by OnViewWithText(R.string.textEntry) - - fun typeText(textToType: String) { - contentRceView.click() - contentRceView.perform(typeTextIntoFocusedView(textToType)) - } - - fun clickOnSubmitButton() { - submitButton.perform(explicitClick()) - } -} diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/compose/TextSubmissionUploadPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/compose/TextSubmissionUploadPage.kt new file mode 100644 index 0000000000..a7f389471c --- /dev/null +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/compose/TextSubmissionUploadPage.kt @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * 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. + * + */ +package com.instructure.student.ui.pages.compose + +import androidx.compose.ui.test.junit4.ComposeTestRule +import com.instructure.canvas.espresso.TypeInRCETextEditor +import com.instructure.canvas.espresso.explicitClick +import com.instructure.composetest.clickToolbarIconButton +import com.instructure.espresso.OnViewWithId +import com.instructure.espresso.OnViewWithText +import com.instructure.espresso.RCETextEditorContentAssertion +import com.instructure.espresso.RCETextEditorHtmlAssertion +import com.instructure.espresso.click +import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.onView +import com.instructure.espresso.page.waitForViewWithId +import com.instructure.espresso.page.waitForViewWithText +import com.instructure.espresso.page.withText +import com.instructure.student.R + +class TextSubmissionUploadPage(private val composeTestRule: ComposeTestRule) : BasePage(R.id.textSubmissionUpload) { + + val submitButton by OnViewWithId(R.id.menuSubmit) + val contentRceView by OnViewWithId(R.id.rce_webView) + val textEntryLabel by OnViewWithText(R.string.textEntry) + + fun typeText(textToType: String) { + contentRceView.perform(TypeInRCETextEditor(textToType)) + } + + fun clickToolbarBackButton() { + composeTestRule.clickToolbarIconButton("Back") + } + + fun clickOnSubmitButton() { + submitButton.perform(explicitClick()) + } + + fun clickCancel() { + onView(withText(com.instructure.pandautils.R.string.cancel)).click() + } + + fun clickSaveDraft() { + waitForViewWithText(com.instructure.pandautils.R.string.save).click() + } + + fun clickDontSaveDraft() { + waitForViewWithText(com.instructure.pandautils.R.string.dontSave).click() + } + + fun assertTextSubmissionContentDescriptionDisplayed(expectedText: String) { + waitForViewWithId(com.instructure.pandautils.R.id.rce_webView).check( + RCETextEditorContentAssertion(expectedText) + ) + } + + fun assertTextSubmissionDisplayed(expectedHtml: String) { + waitForViewWithId(com.instructure.pandautils.R.id.rce_webView).check( + RCETextEditorHtmlAssertion(expectedHtml) + ) + } +} \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentComposeTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentComposeTest.kt index a9ae2d5817..7ebfa56e1a 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentComposeTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentComposeTest.kt @@ -42,6 +42,7 @@ import com.instructure.espresso.ModuleItemInteractions import com.instructure.student.R import com.instructure.student.activity.LoginActivity import com.instructure.student.ui.pages.classic.StudentAssignmentDetailsPage +import com.instructure.student.ui.pages.compose.TextSubmissionUploadPage import org.junit.Rule abstract class StudentComposeTest : StudentTest() { @@ -68,6 +69,13 @@ abstract class StudentComposeTest : StudentTest() { val inboxSignatureSettingsPage = InboxSignatureSettingsPage(composeTestRule) val toDoListPage = ToDoListPage(composeTestRule) val toDoFilterPage = ToDoFilterPage(composeTestRule) - val assignmentDetailsPage = StudentAssignmentDetailsPage(ModuleItemInteractions(R.id.moduleName, R.id.next_item, R.id.prev_item), composeTestRule) + val assignmentDetailsPage = StudentAssignmentDetailsPage( + ModuleItemInteractions( + R.id.moduleName, + R.id.next_item, + R.id.prev_item + ), composeTestRule + ) + val textSubmissionUploadPage = TextSubmissionUploadPage(composeTestRule) val gradeListPage = GradesPage(composeTestRule) } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt index 3d957c64d0..df0858cd50 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt @@ -86,7 +86,6 @@ import com.instructure.student.ui.pages.classic.ShareExtensionStatusPage import com.instructure.student.ui.pages.classic.ShareExtensionTargetPage import com.instructure.student.ui.pages.classic.SubmissionDetailsPage import com.instructure.student.ui.pages.classic.SyllabusPage -import com.instructure.student.ui.pages.classic.TextSubmissionUploadPage import com.instructure.student.ui.pages.classic.UrlSubmissionUploadPage import com.instructure.student.ui.pages.classic.k5.ElementaryCoursePage import com.instructure.student.ui.pages.classic.k5.ElementaryDashboardPage @@ -161,7 +160,6 @@ abstract class StudentTest : CanvasTest() { val pushNotificationsPage = PushNotificationsPage() val emailNotificationsPage = EmailNotificationsPage() val submissionDetailsPage = SubmissionDetailsPage() - val textSubmissionUploadPage = TextSubmissionUploadPage() val syllabusPage = SyllabusPage() val urlSubmissionUploadPage = UrlSubmissionUploadPage() val elementaryDashboardPage = ElementaryDashboardPage() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/compose/AssignmentE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/compose/AssignmentE2ETest.kt index 7c50bd7c08..6752bf9d98 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/compose/AssignmentE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/compose/AssignmentE2ETest.kt @@ -25,7 +25,6 @@ import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvas.espresso.annotations.E2E -import com.instructure.canvas.espresso.annotations.Stub import com.instructure.canvas.espresso.refresh import com.instructure.dataseeding.api.AssignmentsApi import com.instructure.dataseeding.api.SectionsApi diff --git a/automation/espresso/build.gradle b/automation/espresso/build.gradle index 4b7874ce96..fa52d7bd6a 100644 --- a/automation/espresso/build.gradle +++ b/automation/espresso/build.gradle @@ -95,6 +95,7 @@ dependencies { androidTestImplementation Libs.COMPOSE_UI_TEST implementation project(':pandautils') + implementation project(':rceditor') // last update: Sept 30 2017 // old versions: $ANDROID_HOME/extras/android/m2repository/com/android/support/test/ diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/CustomActions.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/CustomActions.kt index f6dcedf239..3de43361ff 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/CustomActions.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/CustomActions.kt @@ -52,6 +52,7 @@ import androidx.viewpager.widget.ViewPager import com.instructure.espresso.assertDisplayed import com.instructure.espresso.retryWithIncreasingDelay import com.instructure.espresso.swipeUp +import instructure.rceditor.RCETextEditor import org.hamcrest.Matcher import org.hamcrest.Matchers import org.hamcrest.Matchers.allOf @@ -381,4 +382,21 @@ fun waitForViewToDisappear(viewMatcher: Matcher, timeoutInSeconds: Long) { fun toString(view: View): String { return HumanReadables.getViewHierarchyErrorMessage(view, null, "", null) +} + +class TypeInRCETextEditor(val text: String) : ViewAction { + override fun getDescription(): String { + return "Enters text into an RCETextEditor" + } + + override fun getConstraints(): Matcher { + return ViewMatchers.isAssignableFrom(RCETextEditor::class.java) + } + + override fun perform(uiController: UiController?, view: View?) { + when(view) { + is RCETextEditor -> view.applyHtml(text) + } + } + } \ No newline at end of file diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/pages/AssignmentDetailsPage.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/pages/AssignmentDetailsPage.kt index 6b6aa1fd35..ca4b3707c9 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/pages/AssignmentDetailsPage.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/pages/AssignmentDetailsPage.kt @@ -316,6 +316,10 @@ open class AssignmentDetailsPage(val moduleItemInteractions: ModuleItemInteracti onView(withText(R.string.done)).click() } + fun clickDraftSubmission() { + onView(withId(R.id.draftTitle) + withText(R.string.submissionDraftAvailableTitle)).click() + } + fun clickSubmissionAndRubric() { onView(allOf(withId(R.id.submissionAndRubricLabel), withText(R.string.submissionAndRubric))).click() } @@ -324,6 +328,12 @@ open class AssignmentDetailsPage(val moduleItemInteractions: ModuleItemInteracti onView(withContentDescription("Send a message about this assignment")).click() } + fun assertDraftAvailableInformation() { + onView(withId(R.id.draftTitle) + withText(R.string.submissionDraftAvailableTitle)).assertDisplayed() + onView(withId(R.id.draftSubtitle) + withText(R.string.submissionDraftAvailableSubtitle)).assertDisplayed() + onView(withId(R.id.draftDivider)).assertDisplayed() + } + //OfflineMethod fun assertSubmitButtonDisabled() { onView(withId(R.id.submitButton)).check(matches(ViewMatchers.isNotEnabled())) diff --git a/automation/espresso/src/main/kotlin/com/instructure/composetest/ComposeCustomActions.kt b/automation/espresso/src/main/kotlin/com/instructure/composetest/ComposeCustomActions.kt new file mode 100644 index 0000000000..ad2b93ac34 --- /dev/null +++ b/automation/espresso/src/main/kotlin/com/instructure/composetest/ComposeCustomActions.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.composetest + +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasParent +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.performClick + +/** + * Clicks an IconButton within a TopAppBar by content description. + * + * @param contentDescription The content description of the IconButton to click (e.g., "Back", "More options") + * @param toolbarTag The test tag of the TopAppBar, defaults to "toolbar" + */ +fun ComposeTestRule.clickToolbarIconButton( + contentDescription: String, + toolbarTag: String = "toolbar" +) { + waitForIdle() + onNode( + hasParent(hasTestTag(toolbarTag)).and( + hasContentDescription(contentDescription) + ) + ).performClick() +} diff --git a/automation/espresso/src/main/kotlin/com/instructure/espresso/CustomViewAssertions.kt b/automation/espresso/src/main/kotlin/com/instructure/espresso/CustomViewAssertions.kt index abcde37572..521eb23ca9 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/espresso/CustomViewAssertions.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/espresso/CustomViewAssertions.kt @@ -30,11 +30,13 @@ import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.assertThat import androidx.viewpager.widget.ViewPager import com.google.android.material.bottomnavigation.BottomNavigationView +import instructure.rceditor.RCETextEditor import junit.framework.AssertionFailedError import org.hamcrest.CoreMatchers.`is` import org.hamcrest.Matcher import org.hamcrest.Matchers import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue class RecyclerViewItemCountAssertion(private val expectedCount: Int) : ViewAssertion { override fun check(view: View, noViewFoundException: NoMatchingViewException?) { @@ -130,6 +132,32 @@ class ViewAlphaAssertion(private val expectedAlpha: Float): ViewAssertion { } } +class RCETextEditorContentAssertion(private val expectedText: String) : ViewAssertion { + override fun check(view: View, noViewFoundException: NoMatchingViewException?) { + noViewFoundException?.let { throw it } + val rceEditor = (view as? RCETextEditor) + ?: throw ClassCastException("View of type ${view.javaClass.simpleName} must be an RCETextEditor") + val actualContent = rceEditor.accessibilityContentDescription + assertTrue( + "Expected RCE content to contain '$expectedText', but was '$actualContent'", + actualContent.contains(expectedText) + ) + } +} + +class RCETextEditorHtmlAssertion(private val expectedHtml: String) : ViewAssertion { + override fun check(view: View, noViewFoundException: NoMatchingViewException?) { + noViewFoundException?.let { throw it } + val rceEditor = (view as? RCETextEditor) + ?: throw ClassCastException("View of type ${view.javaClass.simpleName} must be an RCETextEditor") + val actualHtml = rceEditor.getHtml() ?: "" + assertTrue( + "Expected RCE HTML to contain '$expectedHtml', but was '$actualHtml'", + actualHtml.contains(expectedHtml) + ) + } +} + fun SemanticsNodeInteraction.assertDoesNotExistWithTimeout( timeoutInSeconds: Long, pollIntervalInSeconds: Long = 1L