Files
element-x-ios/UnitTests/Sources/LoggingTests.swift
T
Copilot c41a38159b Only offer joined rooms in the forward and share pickers (#6255)
* Initial plan

* Offer only joined rooms in destination pickers

Co-authored-by: Velin92 <34335419+Velin92@users.noreply.github.com>

* Clean whitespace in share picker test

Co-authored-by: Velin92 <34335419+Velin92@users.noreply.github.com>

* Restrict the shared picker room list to joined rooms

The alternate room summary provider, which backs the forward, share and
Add rooms pickers and global search, now always includes the SDK's
joined filter in its base filter. This replaces the per-call joinedOnly
flags, so Add rooms no longer offers invited rooms or spaces and no
screen can forget the flag.

A filter set before the provider has its room list is now applied once
the list is set, instead of being reset to the default. The share picker
hit this on a cold launch from the share extension.

The forward and share pickers also hide upgraded rooms, matching the
Suggestions section. The SDK has no filter for these.

Also registers the share picker's test file in the project, fixes the
blank-line indentation SwiftFormat flagged, and re-records the picker
snapshots without the upgraded room.

* Check room membership for forwarding suggestions and search breadcrumbs

RoomSummary now has isJoined, built from the room's membership. The
forwarding suggestions and the search breadcrumbs used a missing
joinRequestType to mean joined, which is also the case for rooms that
have been left or banned from. It only worked because the room list's
base filter already drops those.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Velin92 <34335419+Velin92@users.noreply.github.com>
Co-authored-by: Mauro Romito <mauro.romito@element.io>
2026-10-02 12:44:33 +00:00

352 lines
17 KiB
Swift

//
// Copyright 2025 Element Creations Ltd.
// Copyright 2022-2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
@testable import ElementX
import Foundation
@testable import MatrixRustSDK
import MatrixRustSDKMocks
import Testing
@MainActor
final class LoggingTests {
deinit {
Tracing.logsDirectoryOverride = nil
do {
try reloadTracingFileWriter(configuration: .init(path: URL.appGroupLogsDirectory.path(percentEncoded: false),
filePrefix: "console-tests",
fileSuffix: ".log",
maxTotalSizeBytes: 1000,
maxAgeSeconds: 1000))
} catch {
Issue.record(error)
}
}
@Test
func fileLogging() throws {
try setupTest()
let infoLog = UUID().uuidString
MXLog.info(infoLog)
let logFile = try #require(Tracing.logFiles.first)
#expect(try String(contentsOf: logFile, encoding: .utf8).contains(infoLog))
}
@Test
func logLevels() throws {
try setupTest()
let verboseLog = UUID().uuidString
MXLog.verbose(verboseLog)
let logFile = try #require(Tracing.logFiles.first)
#expect(try !String(contentsOf: logFile, encoding: .utf8).contains(verboseLog))
}
/// This is meant to test the `Target.tests.configure(…)`, but at this stage the test is somewhat pointless
/// as it is unlikely to have been called before `tearDown` has manually set the file prefix 😕.
@Test
func targetName() throws {
MXLog.info(UUID().uuidString)
let logFile = try #require(Tracing.logFiles.first)
let target = "tests"
#expect(logFile.lastPathComponent.contains(target))
}
@Test
func roomSummaryContentIsRedacted() throws {
try setupTest()
// Given a room summary that contains sensitive information
let roomName = "Private Conversation"
let lastMessage = "Secret information"
let heroName = "Pseudonym"
let roomSummary = RoomSummary(room: RoomSDKMock(),
id: "myroomid",
joinRequestType: nil,
isJoined: true,
name: roomName,
isDirect: true,
isSpace: false,
avatarURL: nil,
heroes: [.init(userID: "", displayName: heroName)],
activeMembersCount: 0,
lastMessage: AttributedString(lastMessage),
lastMessageDate: .mock,
lastMessageState: nil,
unreadMessagesCount: 0,
unreadMentionsCount: 0,
unreadNotificationsCount: 0,
notificationMode: nil,
canonicalAlias: nil,
alternativeAliases: [],
hasOngoingCall: false,
activeCallIntent: nil,
isMarkedUnread: false,
isFavourite: false,
isTombstoned: false)
// When logging that value
MXLog.info(roomSummary)
// Then the log file should not include the sensitive information
let logFile = try #require(Tracing.logFiles.first)
let content = try String(contentsOf: logFile, encoding: .utf8)
#expect(content.contains(roomSummary.id))
#expect(!content.contains(roomName))
#expect(!content.contains(lastMessage))
#expect(!content.contains(heroName))
}
@Test
func timelineContentIsRedacted() throws {
try setupTest()
// Given timeline items that contain text
let textAttributedString = "TextAttributed"
let textMessage = TextRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "sender"),
content: .init(body: "TextString", formattedBody: AttributedString(textAttributedString)))
let noticeAttributedString = "NoticeAttributed"
let noticeMessage = NoticeRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "sender"),
content: .init(body: "NoticeString", formattedBody: AttributedString(noticeAttributedString)))
let emoteAttributedString = "EmoteAttributed"
let emoteMessage = EmoteRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "sender"),
content: .init(body: "EmoteString", formattedBody: AttributedString(emoteAttributedString)))
let imageMessage = ImageRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "sender"),
content: .init(filename: "ImageString",
caption: "ImageString",
imageInfo: .mockImage,
thumbnailInfo: nil))
let videoMessage = VideoRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "sender"),
content: .init(filename: "VideoString",
caption: "VideoString",
videoInfo: .mockVideo,
thumbnailInfo: nil))
let fileMessage = FileRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "sender"),
content: .init(filename: "FileString",
caption: "FileString",
source: nil,
fileSize: nil,
thumbnailSource: nil,
contentType: nil))
// When logging that value
MXLog.info(textMessage)
MXLog.info(noticeMessage)
MXLog.info(emoteMessage)
MXLog.info(imageMessage)
MXLog.info(videoMessage)
MXLog.info(fileMessage)
// Then the log file should not include the text content
let logFile = try #require(Tracing.logFiles.first)
let content = try String(contentsOf: logFile, encoding: .utf8)
#expect(content.contains(textMessage.id.uniqueID.value))
#expect(!content.contains(textMessage.body))
#expect(!content.contains(textAttributedString))
#expect(content.contains(noticeMessage.id.uniqueID.value))
#expect(!content.contains(noticeMessage.body))
#expect(!content.contains(noticeAttributedString))
#expect(content.contains(emoteMessage.id.uniqueID.value))
#expect(!content.contains(emoteMessage.body))
#expect(!content.contains(emoteAttributedString))
#expect(content.contains(imageMessage.id.uniqueID.value))
#expect(!content.contains(imageMessage.body))
#expect(content.contains(videoMessage.id.uniqueID.value))
#expect(!content.contains(videoMessage.body))
#expect(content.contains(fileMessage.id.uniqueID.value))
#expect(!content.contains(fileMessage.body))
}
@Test
func rustMessageContentIsRedacted() throws {
try setupTest()
// Given message content that contain text
let textString = "TextString"
let rustTextMessage = TextMessageContent(body: "",
formatted: FormattedBody(format: .html, body: "<b>\(textString)</b>"))
let noticeString = "NoticeString"
let rustNoticeMessage = NoticeMessageContent(body: noticeString,
formatted: FormattedBody(format: .html, body: "<b>\(noticeString)</b>"))
let emoteString = "EmoteString"
let rustEmoteMessage = EmoteMessageContent(body: emoteString,
formatted: FormattedBody(format: .html, body: "<b>\(emoteString)</b>"))
let rustImageMessage = ImageMessageContent(filename: "ImageString",
caption: "ImageString",
formattedCaption: nil,
source: MediaSourceSDKMock(),
info: nil)
let rustVideoMessage = VideoMessageContent(filename: "VideoString",
caption: "VideoString",
formattedCaption: nil,
source: MediaSourceSDKMock(),
info: nil)
let rustFileMessage = FileMessageContent(filename: "FileString",
caption: "FileString",
formattedCaption: nil,
source: MediaSourceSDKMock(),
info: nil)
// When logging that value
MXLog.info(rustTextMessage)
MXLog.info(rustNoticeMessage)
MXLog.info(rustEmoteMessage)
MXLog.info(rustImageMessage)
MXLog.info(rustVideoMessage)
MXLog.info(rustFileMessage)
// Then the log file should not include the text content
let logFile = try #require(Tracing.logFiles.first)
let content = try String(contentsOf: logFile, encoding: .utf8)
#expect(content.contains(String(describing: TextMessageContent.self)))
#expect(!content.contains(textString))
#expect(content.contains(String(describing: NoticeMessageContent.self)))
#expect(!content.contains(noticeString))
#expect(content.contains(String(describing: EmoteMessageContent.self)))
#expect(!content.contains(emoteString))
#expect(content.contains(String(describing: ImageMessageContent.self)))
#expect(!content.contains(rustImageMessage.filename))
#expect(content.contains(String(describing: VideoMessageContent.self)))
#expect(!content.contains(rustVideoMessage.filename))
#expect(content.contains(String(describing: FileMessageContent.self)))
#expect(!content.contains(rustFileMessage.filename))
}
@Test
func logFileSorting() throws {
try setupTest(redirectTracingFileWriter: false)
// Given a collection of log files.
#expect(Tracing.logFiles.isEmpty)
// When creating new logs.
let logsFileDirectory = Tracing.logsDirectory
for i in 1...5 {
let filename = "console.\(i).log"
try "console".write(to: logsFileDirectory.appending(path: filename), atomically: true, encoding: .utf8)
}
for i in 1...5 {
let nseFilename = "console-nse.\(i).log"
try "nse".write(to: logsFileDirectory.appending(path: nseFilename), atomically: true, encoding: .utf8)
}
// Then the logs should be sorted chronologically (newest first) and not alphabetically.
#expect(Tracing.logFiles.map(\.lastPathComponent) ==
["console-nse.5.log",
"console-nse.4.log",
"console-nse.3.log",
"console-nse.2.log",
"console-nse.1.log",
"console.5.log",
"console.4.log",
"console.3.log",
"console.2.log",
"console.1.log"])
// When updating the oldest log file.
let currentLogFile = logsFileDirectory.appending(path: "console.1.log")
let fileHandle = try FileHandle(forWritingTo: currentLogFile)
try fileHandle.seekToEnd()
try fileHandle.write(contentsOf: Data("newline".utf8))
try fileHandle.close()
// Then that file should now be the first log file.
#expect(Tracing.logFiles.map(\.lastPathComponent) ==
["console.1.log",
"console-nse.5.log",
"console-nse.4.log",
"console-nse.3.log",
"console-nse.2.log",
"console-nse.1.log",
"console.5.log",
"console.4.log",
"console.3.log",
"console.2.log"])
}
// MARK: - Helpers
/// There is something weird with Rust logging where the file writing handle won't notice that the file it is writing
/// to has been deleted. So in order to run the tests that validate the file output, we must use a new directory
/// to start with a fresh state (as calling ``Tracing.deleteLogFiles`` would trigger the bug).
private func setupTest(name: String = #function, redirectTracingFileWriter: Bool = true) throws {
let testDirectory = URL.appGroupLogsDirectory.appending(component: name, directoryHint: .isDirectory)
// CI retries re-run the suite on the same simulator, so clear out any log files left
// behind by a previous attempt. No writer points at the directory yet, so this is safe.
try? FileManager.default.removeItem(at: testDirectory)
Tracing.logsDirectoryOverride = testDirectory
try? FileManager.default.createDirectory(at: testDirectory, withIntermediateDirectories: true)
// Make an assertion before redirecting the logs as it the SDK is likely to put an empty file
// in the directory, ready to be written to.
#expect(Tracing.logFiles.isEmpty)
if redirectTracingFileWriter {
try reloadTracingFileWriter(configuration: .init(path: testDirectory.path(percentEncoded: false),
filePrefix: "console",
fileSuffix: ".log",
maxTotalSizeBytes: 1000,
maxAgeSeconds: 1000))
}
}
}