diff --git a/RxNote/RxNote.xcodeproj/project.pbxproj b/RxNote/RxNote.xcodeproj/project.pbxproj index 893b0bf..079fdd9 100644 --- a/RxNote/RxNote.xcodeproj/project.pbxproj +++ b/RxNote/RxNote.xcodeproj/project.pbxproj @@ -76,6 +76,16 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + DF407A362F54C7340062B319 /* Exceptions for "RxNoteUITests" folder in "RxNoteClipsUITests" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + utils/accessibility.swift, + utils/DotEnv.swift, + utils/launch.swift, + utils/signin.swift, + ); + target = DF6625AA2F29138400333552 /* RxNoteClipsUITests */; + }; DF44CC952F548B6100A2E4A4 /* Exceptions for "RxNote" folder in "RxNoteClips" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -129,8 +139,8 @@ DF66255F2F29136F00333552 /* RxNote */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( - DF44CC952F548B6100A2E4A4 /* Exceptions for "RxNote" folder in "RxNoteClips" target */, DF44CCB02F548C4800A2E4A4 /* Exceptions for "RxNote" folder in "RxNote" target */, + DF44CC952F548B6100A2E4A4 /* Exceptions for "RxNote" folder in "RxNoteClips" target */, ); path = RxNote; sourceTree = ""; @@ -142,6 +152,9 @@ }; DF66257B2F29137000333552 /* RxNoteUITests */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + DF407A362F54C7340062B319 /* Exceptions for "RxNoteUITests" folder in "RxNoteClipsUITests" target */, + ); path = RxNoteUITests; sourceTree = ""; }; diff --git a/RxNote/RxNote/ContentView.swift b/RxNote/RxNote/ContentView.swift index 288429c..14aaf42 100644 --- a/RxNote/RxNote/ContentView.swift +++ b/RxNote/RxNote/ContentView.swift @@ -10,6 +10,9 @@ import SwiftUI struct ContentView: View { var authManager: OAuthManager + + /// Pending deep link URL received before authentication completed + @State private var pendingDeepLinkURL: URL? var body: some View { Group { @@ -17,7 +20,7 @@ struct ContentView: View { case .unknown: AuthLoadingView() case .authenticated: - AdaptiveRootView() + AdaptiveRootView(pendingDeepLinkURL: $pendingDeepLinkURL) case .unauthenticated: RxSignInView( manager: authManager, @@ -33,6 +36,24 @@ struct ContentView: View { #endif } } + // Handle deep links at the top level to capture them before auth completes + .onOpenURL { url in + if authManager.authState == .authenticated { + // Will be handled by AdaptiveRootView's onOpenURL + } else { + // Store for later processing after authentication + pendingDeepLinkURL = url + } + } + .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { userActivity in + if let url = userActivity.webpageURL { + if authManager.authState == .authenticated { + // Will be handled by AdaptiveRootView + } else { + pendingDeepLinkURL = url + } + } + } } } diff --git a/RxNote/RxNote/Navigation/NavigationManager.swift b/RxNote/RxNote/Navigation/NavigationManager.swift index 0599612..c0277f2 100644 --- a/RxNote/RxNote/Navigation/NavigationManager.swift +++ b/RxNote/RxNote/Navigation/NavigationManager.swift @@ -9,6 +9,18 @@ import Observation import RxNoteCore import SwiftUI +/// Errors that can occur during deep link handling +enum DeepLinkError: LocalizedError { + case invalidURL(String) + + var errorDescription: String? { + switch self { + case .invalidURL(let url): + return "Unable to open link: \(url)" + } + } +} + /// Navigation destinations for NavigationStack enum AppDestination: Hashable { case noteDetail(id: Int) @@ -107,15 +119,19 @@ final class NavigationManager { let scanResponse = try await qrCodeService.scanQrCode(qrcontent: url.absoluteString) // Extract note ID from the resolved URL - if let noteId = extractNoteId(from: scanResponse.url) { - if selectedTab != .notes { - selectedTab = .notes - } - selectedNoteId = noteId - notesNavigationPath.append(AppDestination.noteDetail(id: noteId)) + guard let noteId = extractNoteId(from: scanResponse.url) else { + throw DeepLinkError.invalidURL(url.absoluteString) + } + + if selectedTab != .notes { + selectedTab = .notes } + selectedNoteId = noteId + notesNavigationPath.append(AppDestination.noteDetail(id: noteId)) } catch { deepLinkError = error + // Small delay to ensure view hierarchy is stable before showing alert + try? await Task.sleep(for: .milliseconds(100)) showDeepLinkError = true } } diff --git a/RxNote/RxNote/Views/AdaptiveRootView.swift b/RxNote/RxNote/Views/AdaptiveRootView.swift index 07f6197..3b1c091 100644 --- a/RxNote/RxNote/Views/AdaptiveRootView.swift +++ b/RxNote/RxNote/Views/AdaptiveRootView.swift @@ -11,6 +11,9 @@ import SwiftUI struct AdaptiveRootView: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass @State private var navigationManager = NavigationManager() + + /// Binding to pending deep link URL from ContentView (received before auth completed) + @Binding var pendingDeepLinkURL: URL? var body: some View { Group { @@ -37,6 +40,16 @@ struct AdaptiveRootView: View { await navigationManager.handleDeepLink(url) } } + // Process pending deep link once when view first appears + .onAppear { + if let url = pendingDeepLinkURL { + let urlToProcess = url + pendingDeepLinkURL = nil + Task { + await navigationManager.handleDeepLink(urlToProcess) + } + } + } .alert("Deep Link Error", isPresented: $navigationManager.showDeepLinkError) { Button("OK", role: .cancel) {} .accessibilityIdentifier("deep-link-error-ok-button") @@ -62,5 +75,5 @@ struct AdaptiveRootView: View { } #Preview { - AdaptiveRootView() + AdaptiveRootView(pendingDeepLinkURL: .constant(nil)) } diff --git a/RxNote/RxNote/Views/AppClip/AppClipRootView.swift b/RxNote/RxNote/Views/AppClip/AppClipRootView.swift index 7957deb..2ff77d8 100644 --- a/RxNote/RxNote/Views/AppClip/AppClipRootView.swift +++ b/RxNote/RxNote/Views/AppClip/AppClipRootView.swift @@ -17,6 +17,7 @@ import SwiftUI /// 4. If 403, show access denied struct AppClipRootView: View { @State private var noteId: Int? + @State private var noteDetail: NoteDetail? @State private var parseError: String? @State private var oauthManager = OAuthManager( configuration: AppConfiguration.shared.rxAuthConfiguration @@ -25,6 +26,9 @@ struct AppClipRootView: View { /// Service for QR code resolution private let qrCodeService = QrCodeService() + /// Token storage for optional auth + private let tokenStorage = KeychainTokenStorage(serviceName: "com.rxlab.RxNote") + /// Store resolved URL for retry after authentication @State private var resolvedNoteUrl: String? @@ -74,49 +78,43 @@ struct AppClipRootView: View { .accessibilityIdentifier("invalid-url") } else if isLoading { loadingView - } else if noteId != nil { - // TODO: Replace with NoteDetailView after OpenAPI regen - ContentUnavailableView( - "Note Preview", - systemImage: "note.text", - description: Text("Note detail view coming soon") - ) - .toolbar { - if oauthManager.currentUser != nil { - ToolbarItem(placement: .primaryAction) { - Menu { - Button(role: .destructive) { - showSignOutConfirmation = true + } else if let note = noteDetail, let id = noteId { + // Display note in read-only mode (no edit button for App Clips) + NoteEditorView(mode: .view(noteId: id, existing: note)) + .toolbar { + if oauthManager.currentUser != nil { + ToolbarItem(placement: .primaryAction) { + Menu { + Button(role: .destructive) { + showSignOutConfirmation = true + } label: { + Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") + } + .accessibilityIdentifier("app-clips-sign-out-button") } label: { - Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") + Image(systemName: "ellipsis.circle") } - .accessibilityIdentifier("app-clips-sign-out-button") - } label: { - Image(systemName: "ellipsis.circle") + .accessibilityIdentifier("app-clips-more-menu") } - .accessibilityIdentifier("app-clips-more-menu") } } - } - .confirmationDialog( - title: "Sign Out", - message: "Are you sure you want to sign out?", - confirmButtonTitle: "Sign Out", - isPresented: $showSignOutConfirmation - ) { - Task { - await oauthManager.logout() - if let qrcontent = originalQrContent { - await fetchNoteFromQrCode(qrcontent) + .confirmationDialog( + title: "Sign Out", + message: "Are you sure you want to sign out?", + confirmButtonTitle: "Sign Out", + isPresented: $showSignOutConfirmation + ) { + Task { + await oauthManager.logout() + if let qrcontent = originalQrContent { + await fetchNoteFromQrCode(qrcontent) + } } } - } } else if let error = loadError { errorView(error: error) - } else if noteId == nil { - ProgressView("Waiting for URL...") } else { - ProgressView("Loading...") + ProgressView("Waiting for URL...") } } } @@ -187,6 +185,7 @@ struct AppClipRootView: View { accessDenied = false parseError = nil loadError = nil + noteDetail = nil isLoading = true // Store the original QR content for retry after auth @@ -195,15 +194,20 @@ struct AppClipRootView: View { defer { isLoading = false } do { + // Step 1: Scan QR code to get the API URL let scanResponse = try await qrCodeService.scanQrCode(qrcontent: qrcontent) resolvedNoteUrl = scanResponse.url // Extract note ID from URL - if let id = extractNoteId(from: scanResponse.url) { - noteId = id - } else { + guard let id = extractNoteId(from: scanResponse.url) else { parseError = "Could not parse note ID from URL" + return } + noteId = id + + // Step 2: Fetch note detail directly from the resolved URL + let note = try await fetchNoteFromUrl(scanResponse.url) + noteDetail = note } catch let error as APIError { switch error { @@ -221,6 +225,61 @@ struct AppClipRootView: View { } } + /// Fetch note detail directly from the API URL with optional authentication + private func fetchNoteFromUrl(_ urlString: String) async throws -> NoteDetail { + guard let url = URL(string: urlString) else { + throw APIError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + + // Add auth token if available (optional auth for App Clips) + if let accessToken = tokenStorage.getAccessToken() { + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + } + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } + + switch httpResponse.statusCode { + case 200: + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let dateString = try container.decode(String.self) + + // Try ISO8601 with fractional seconds first + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: dateString) { + return date + } + + // Fallback to standard ISO8601 + formatter.formatOptions = [.withInternetDateTime] + if let date = formatter.date(from: dateString) { + return date + } + + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode date: \(dateString)") + } + return try decoder.decode(NoteDetail.self, from: data) + case 401: + throw APIError.unauthorized + case 403: + throw APIError.forbidden + case 404: + throw APIError.notFound + default: + throw APIError.serverError("HTTP \(httpResponse.statusCode)") + } + } + // MARK: - Helpers private func extractNoteId(from urlString: String) -> Int? { diff --git a/RxNote/RxNote/Views/Notes/NoteDetailView.swift b/RxNote/RxNote/Views/Notes/NoteDetailView.swift index effa52e..55ce5e5 100644 --- a/RxNote/RxNote/Views/Notes/NoteDetailView.swift +++ b/RxNote/RxNote/Views/Notes/NoteDetailView.swift @@ -35,9 +35,11 @@ struct NoteDetailView: View { if isEditing { NoteEditorView( mode: .edit(noteId: noteId, existing: note), - onSave: { _ in - isEditing = false - Task { await viewModel.fetchNote(id: noteId) } + onSave: { updatedNote in + Task { + await viewModel.fetchNote(id: noteId) + isEditing = false + } }, onCancel: { isEditing = false diff --git a/RxNote/RxNote/Views/Notes/NoteEditorView.swift b/RxNote/RxNote/Views/Notes/NoteEditorView.swift index abb512a..99e96d0 100644 --- a/RxNote/RxNote/Views/Notes/NoteEditorView.swift +++ b/RxNote/RxNote/Views/Notes/NoteEditorView.swift @@ -195,11 +195,15 @@ struct NoteEditorView: View { } if viewModel.isReadOnly { - Button { - onEdit?() - } label: { - Image(systemName: "pencil") - .font(.title3.weight(.medium)) + // Only show edit button if onEdit callback is provided + if let onEdit { + Button { + onEdit() + } label: { + Image(systemName: "pencil") + .font(.title3.weight(.medium)) + } + .accessibilityIdentifier("note-detail-edit-button") } } else if viewModel.isSaving { ProgressView() @@ -209,6 +213,7 @@ struct NoteEditorView: View { } label: { Image(systemName: "checkmark") } + .accessibilityIdentifier("note-save-button") .disabled(!viewModel.canSave || viewModel.hasUploadsInProgress) } } @@ -272,10 +277,12 @@ struct NoteEditorView: View { Text(viewModel.title) .font(.title.weight(.bold)) .padding(.horizontal, 16) + .accessibilityIdentifier("note-detail-title") } else { TextField("Title", text: $viewModel.title, axis: .vertical) .font(.title.weight(.bold)) .padding(.horizontal, 16) + .accessibilityIdentifier("note-title-field") } // Content @@ -299,6 +306,7 @@ struct NoteEditorView: View { .scrollContentBackground(.hidden) .padding(.horizontal, 12) .frame(minHeight: 200) + .accessibilityIdentifier("note-content-field") } } @@ -307,6 +315,7 @@ struct NoteEditorView: View { actionButtonsSection } } + .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 12) } .scrollDismissesKeyboard(.interactively) @@ -566,11 +575,8 @@ struct NoteEditorView: View { private func saveNote() async { if let note = await viewModel.save() { - if let onSave { - onSave(note) - } else { - dismiss() - } + onSave?(note) + dismiss() } } diff --git a/RxNote/RxNote/Views/Notes/NoteListView.swift b/RxNote/RxNote/Views/Notes/NoteListView.swift index 3a25953..2aedf64 100644 --- a/RxNote/RxNote/Views/Notes/NoteListView.swift +++ b/RxNote/RxNote/Views/Notes/NoteListView.swift @@ -96,6 +96,7 @@ struct NoteListView: View { NavigationLink(value: AppDestination.noteDetail(id: note.id)) { NoteRow(note: note) } + .accessibilityIdentifier("note-row-\(note.id)") .onAppear { // Load more when reaching near the end if note.id == viewModel.notes.last?.id, viewModel.hasNextPage { diff --git a/RxNote/RxNoteClipsUITests/RxStorageClipsUITests.swift b/RxNote/RxNoteClipsUITests/RxStorageClipsUITests.swift index 9759010..a09b56f 100644 --- a/RxNote/RxNoteClipsUITests/RxStorageClipsUITests.swift +++ b/RxNote/RxNoteClipsUITests/RxStorageClipsUITests.swift @@ -31,6 +31,7 @@ final class RxNoteClipsUITests: XCTestCase { XCTAssertFalse(errorTitle.exists) XCTAssertFalse(app.appClipsSignInRequired.exists) + XCTAssertTrue(app.noteDetailTitle.waitForExistence(timeout: 15), "Note detail should be displayed after sign-in") } func testPrivateNoteRequiresSignIn() throws { @@ -40,6 +41,9 @@ final class RxNoteClipsUITests: XCTestCase { XCTAssertTrue(app.appClipsSignInRequired.waitForExistence(timeout: 10)) try app.signInWithEmailAndPassword(isAppclips: true) + + // Verify note detail is loaded after sign-in + XCTAssertTrue(app.noteDetailTitle.waitForExistence(timeout: 15), "Note detail should be displayed after sign-in") } // MARK: - Error Handling Tests diff --git a/RxNote/RxNoteUITests/RxNoteDeepLinkTests.swift b/RxNote/RxNoteUITests/RxNoteDeepLinkTests.swift new file mode 100644 index 0000000..1f5b5bb --- /dev/null +++ b/RxNote/RxNoteUITests/RxNoteDeepLinkTests.swift @@ -0,0 +1,65 @@ +// +// RxNoteDeepLinkTests.swift +// RxNoteUITests +// +// UI tests for deep link handling +// + +import XCTest + +final class RxNoteDeepLinkTests: XCTestCase { + // MARK: - Public Note Deep Link + + func testDeepLinkToPublicNote() throws { + let app = launchAppWithDeepLink(noteId: 1) + try app.signInWithEmailAndPassword(expectsDeepLinkNavigation: true) + + // Wait for note detail to load + XCTAssertTrue(app.noteDetailTitle.waitForExistence(timeout: 15), "Note detail should load via deep link") + XCTAssertEqual(app.noteDetailTitle.label, "Public Test Note") + } + + // MARK: - Private Note Deep Link (accessible) + + func testDeepLinkToPrivateNote() throws { + let app = launchAppWithDeepLink(noteId: 2) + try app.signInWithEmailAndPassword(expectsDeepLinkNavigation: true) + + // Wait for note detail to load after sign-in + XCTAssertTrue(app.noteDetailTitle.waitForExistence(timeout: 30), "Private note detail should load after sign-in") + XCTAssertEqual(app.noteDetailTitle.label, "Private Test Note") + } + + // MARK: - Private Note Deep Link (access denied) + + func testDeepLinkToPrivateNoteBelongsToOthers() throws { + let app = launchAppWithDeepLink(noteId: 3) + try app.signInWithEmailAndPassword(expectsDeepLinkNavigation: true) + + // Deep link navigates to note detail, but note fetch fails with access denied + let errorAlert = app.alerts["Deep Link Error"].firstMatch + XCTAssertTrue(errorAlert.waitForExistence(timeout: 30), "Error alert should appear for access denied note") + } + + // MARK: - Invalid Deep Link URL + + func testDeepLinkInvalidUrl() throws { + let app = launchAppWithDeepLink(url: "rxnote://invalid/path") + try app.signInWithEmailAndPassword(expectsDeepLinkNavigation: true) + // Should show an error or return to normal state + // The app should handle invalid paths gracefully + let errorAlert = app.alerts["Deep Link Error"].firstMatch + XCTAssertTrue(errorAlert.waitForExistence(timeout: 15), "Deep link error alert should appear for invalid URL") + } + + // MARK: - Non-existent Note Deep Link + + func testDeepLinkNonExistentNote() throws { + let app = launchAppWithDeepLink(noteId: 999999) + try app.signInWithEmailAndPassword(expectsDeepLinkNavigation: true) + + // Should show an error alert + let errorAlert = app.alerts["Deep Link Error"].firstMatch + XCTAssertTrue(errorAlert.waitForExistence(timeout: 30), "Deep link error alert should appear for non-existent note") + } +} diff --git a/RxNote/RxNoteUITests/RxNoteNoteCrudTests.swift b/RxNote/RxNoteUITests/RxNoteNoteCrudTests.swift new file mode 100644 index 0000000..49897bf --- /dev/null +++ b/RxNote/RxNoteUITests/RxNoteNoteCrudTests.swift @@ -0,0 +1,143 @@ +// +// RxNoteNoteCrudTests.swift +// RxNoteUITests +// +// UI tests for note CRUD operations +// + +import XCTest + +final class RxNoteNoteCrudTests: XCTestCase { + // MARK: - Create Note + + func testCreateNote() throws { + let app = launchApp() + try app.signInWithEmailAndPassword() + + // Tap add note button + XCTAssertTrue(app.addNoteButton.waitForExistence(timeout: 10), "Add note button should exist") + app.addNoteButton.tap() + + // Fill in title + XCTAssertTrue(app.noteTitleField.waitForExistence(timeout: 10), "Title field should exist") + app.noteTitleField.tap() + app.noteTitleField.typeText("UI Test Note") + + // Fill in content + XCTAssertTrue(app.noteContentField.waitForExistence(timeout: 5), "Content field should exist") + app.noteContentField.tap() + app.noteContentField.typeText("This is a test note created by UI tests") + + // Tap save + XCTAssertTrue(app.noteSaveButton.waitForExistence(timeout: 5), "Save button should exist") + app.noteSaveButton.tap() + + // Verify we return to the list and the note appears + let noteTitle = app.staticTexts["UI Test Note"].firstMatch + XCTAssertTrue(noteTitle.waitForExistence(timeout: 15), "Created note should appear in the list") + } + + // MARK: - View Note Detail + + func testViewNoteDetail() throws { + let app = launchApp() + try app.signInWithEmailAndPassword() + + // Wait for notes list to load + let noteText = app.staticTexts["Private Test Note"].firstMatch + XCTAssertTrue(noteText.waitForExistence(timeout: 15), "Private Test Note should appear in the list") + + // Tap on the note row (tap the static text which is inside the row) + noteText.tap() + + // Verify detail view shows the title + XCTAssertTrue(app.noteDetailTitle.waitForExistence(timeout: 10), "Note detail title should exist") + XCTAssertEqual(app.noteDetailTitle.label, "Private Test Note") + } + + // MARK: - Edit Note + + func testEditNote() throws { + let app = launchApp() + try app.signInWithEmailAndPassword() + + // First create a note to edit + XCTAssertTrue(app.addNoteButton.waitForExistence(timeout: 10), "Add note button should exist") + app.addNoteButton.tap() + + XCTAssertTrue(app.noteTitleField.waitForExistence(timeout: 10), "Title field should exist") + app.noteTitleField.tap() + app.noteTitleField.typeText("Note to Edit") + + app.noteSaveButton.tap() + + // Wait for navigation to detail view (after save, app navigates to detail) + XCTAssertTrue(app.noteDetailTitle.waitForExistence(timeout: 15), "Note detail title should exist") + XCTAssertEqual(app.noteDetailTitle.label, "Note to Edit") + + // Tap edit button + XCTAssertTrue(app.noteDetailEditButton.waitForExistence(timeout: 5), "Edit button should exist") + app.noteDetailEditButton.tap() + + // Verify editor opens with title field editable + XCTAssertTrue(app.noteTitleField.waitForExistence(timeout: 10), "Title field should exist in edit mode") + + // Modify the title - clear existing text and type new one + app.noteTitleField.tap() + app.noteTitleField.press(forDuration: 1.0) + let selectAll = app.menuItems["Select All"].firstMatch + if selectAll.waitForExistence(timeout: 3) { + selectAll.tap() + } + app.noteTitleField.typeText("Updated Note Title") + + // Save the edited note + app.noteSaveButton.tap() + + // Verify the title is updated in detail view + XCTAssertTrue(app.staticTexts["Updated Note Title"].waitForExistence(timeout: 15), "Note detail title should exist after edit") + } + + // MARK: - Delete Note + + func testDeleteNote() throws { + let app = launchApp() + try app.signInWithEmailAndPassword() + + // First create a note to delete + XCTAssertTrue(app.addNoteButton.waitForExistence(timeout: 10), "Add note button should exist") + app.addNoteButton.tap() + + XCTAssertTrue(app.noteTitleField.waitForExistence(timeout: 10), "Title field should exist") + app.noteTitleField.tap() + app.noteTitleField.typeText("Note to Delete") + + // check save button exists before tapping + XCTAssertTrue(app.noteSaveButton.waitForExistence(timeout: 5), "Save button should exist") + app.noteSaveButton.tap() + + // wait back button + let backButton = app.navigationBars.buttons["Notes"].firstMatch + XCTAssertTrue(backButton.waitForExistence(timeout: 5), "Back button should exist") + backButton.tap() // Go back to list to ensure the note is created before we try + + // Wait for the note to appear in the list + let noteToDelete = app.staticTexts["Note to Delete"].firstMatch + XCTAssertTrue(noteToDelete.waitForExistence(timeout: 15), "Note to delete should appear in the list") + + // Find the cell containing this note and swipe on it + // The staticText is inside a cell, we need to swipe on the cell for delete action to work + let cell = app.cells.containing(.staticText, identifier: "Note to Delete").firstMatch + XCTAssertTrue(cell.waitForExistence(timeout: 5), "Cell containing the note should exist") + cell.swipeLeft() + + // Tap delete button + let deleteButton = app.buttons["Delete"].firstMatch + if deleteButton.waitForExistence(timeout: 5) { + deleteButton.tap() + } + + // Verify the note is removed from the list + XCTAssertTrue(noteToDelete.waitForNonExistence(timeout: 10), "Deleted note should no longer appear in the list") + } +} diff --git a/RxNote/RxNoteUITests/utils/accessibility.swift b/RxNote/RxNoteUITests/utils/accessibility.swift new file mode 100644 index 0000000..78bd3b4 --- /dev/null +++ b/RxNote/RxNoteUITests/utils/accessibility.swift @@ -0,0 +1,93 @@ +// +// accessibility.swift +// RxNoteUITests +// +// Accessibility identifier helpers for UI tests +// + +import XCTest + +extension XCUIApplication { + // MARK: - Tabs + + var tabNotes: XCUIElement { + tabBars.buttons["tab-notes"].firstMatch + } + + var tabSettings: XCUIElement { + tabBars.buttons["tab-settings"].firstMatch + } + + // MARK: - Note List + + var addNoteButton: XCUIElement { + buttons["add-note-button"].firstMatch + } + + func noteRow(id: Int) -> XCUIElement { + // NavigationLink is not exposed as a button, use descendants to find by identifier + descendants(matching: .any).matching(identifier: "note-row-\(id)").firstMatch + } + + // MARK: - Note Editor + + var noteTitleField: XCUIElement { + textFields["note-title-field"].firstMatch + } + + var noteContentField: XCUIElement { + textViews["note-content-field"].firstMatch + } + + var noteSaveButton: XCUIElement { + buttons["note-save-button"].firstMatch + } + + // MARK: - Note Detail + + var noteDetailTitle: XCUIElement { + staticTexts["note-detail-title"].firstMatch + } + + var noteDetailEditButton: XCUIElement { + buttons["note-detail-edit-button"].firstMatch + } + + // MARK: - Deep Link + + var deepLinkErrorOkButton: XCUIElement { + buttons["deep-link-error-ok-button"].firstMatch + } + + var deepLinkErrorMessage: XCUIElement { + staticTexts["deep-link-error-message"].firstMatch + } + + // MARK: - QR Code + + var qrScannerButton: XCUIElement { + buttons["qr-scanner-button"].firstMatch + } + + // MARK: - App Clips + + var appClipsSignInRequired: XCUIElement { + staticTexts["Sign In Required"].firstMatch + } + + var appClipsAccessDenined: XCUIElement { + staticTexts["app-clips-access-denied"].firstMatch + } + + var appClipsMoreMenu: XCUIElement { + buttons["app-clips-more-menu"].firstMatch + } + + var appClipsSignOutButton: XCUIElement { + buttons["app-clips-sign-out-button"].firstMatch + } + + var appClipsInvalidUrl: XCUIElement { + otherElements["invalid-url"].firstMatch + } +} diff --git a/RxNote/RxNoteUITests/utils/signin.swift b/RxNote/RxNoteUITests/utils/signin.swift index 726d8d0..b66760e 100644 --- a/RxNote/RxNoteUITests/utils/signin.swift +++ b/RxNote/RxNoteUITests/utils/signin.swift @@ -11,7 +11,11 @@ import XCTest private let logger = Logger(subsystem: "app.rxlab.RxNoteUITests", category: "signin") extension XCUIApplication { - func signInWithEmailAndPassword(isAppclips: Bool = false) throws { + /// Sign in with email and password using the OAuth flow + /// - Parameters: + /// - isAppclips: Set to true for App Clips (skips notes view check) + /// - expectsDeepLinkNavigation: Set to true when a deep link will navigate away from notes view + func signInWithEmailAndPassword(isAppclips: Bool = false, expectsDeepLinkNavigation: Bool = false) throws { // Load .env file and read credentials (with fallback to process environment for CI) let envVars = DotEnv.loadWithFallback() @@ -89,7 +93,9 @@ extension XCUIApplication { logger.info("✅ Sign-in form submitted, waiting for callback...") // find notes tab (main view after sign in) - if !isAppclips { + // Skip this check for App Clips - they don't show the Notes list + // Skip this check when deep link navigation is expected - app will navigate to note detail + if !isAppclips && !expectsDeepLinkNavigation { let exist = staticTexts["Notes"].waitForExistence(timeout: 30) XCTAssertTrue(exist, "Failed to sign in and reach notes view") } diff --git a/RxNote/TestPlan.xctestplan b/RxNote/TestPlan.xctestplan index de82583..45005d7 100644 --- a/RxNote/TestPlan.xctestplan +++ b/RxNote/TestPlan.xctestplan @@ -9,10 +9,10 @@ } ], "defaultOptions" : { - "testExecutionOrdering" : "random", "executeInParallel" : true, - "parallelizationMaximumWorkers" : 2, "maximumTestRepetitions" : 3, + "parallelizationMaximumWorkers" : 2, + "testExecutionOrdering" : "random", "testRepetitionMode" : "retryOnFailure", "testTimeoutsEnabled" : true }, @@ -20,8 +20,15 @@ { "target" : { "containerPath" : "container:RxNote.xcodeproj", - "identifier" : "DF66256D2F29137000333552", - "name" : "RxNoteTests" + "identifier" : "DF6625A02F29138400333552", + "name" : "RxNoteClipsTests" + } + }, + { + "target" : { + "containerPath" : "container:RxNote.xcodeproj", + "identifier" : "DF6625AA2F29138400333552", + "name" : "RxNoteClipsUITests" } }, { @@ -30,6 +37,13 @@ "identifier" : "DF6625772F29137000333552", "name" : "RxNoteUITests" } + }, + { + "target" : { + "containerPath" : "container:RxNote.xcodeproj", + "identifier" : "DF66256D2F29137000333552", + "name" : "RxNoteTests" + } } ], "version" : 1 diff --git a/backend/app/(auth)/login/page.tsx b/backend/app/(auth)/login/page.tsx index f5098b9..ec8b1b3 100644 --- a/backend/app/(auth)/login/page.tsx +++ b/backend/app/(auth)/login/page.tsx @@ -23,9 +23,9 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
- Storage Management + RxNote - Sign in to access your storage items and manage your inventory + Sign in to access your notes and manage your content