Allow user to export items (#51)

This commit is contained in:
Katherine Bertelsen 2024-04-20 13:13:38 -05:00 committed by GitHub
parent 51464d8866
commit 99817fa2f6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 462 additions and 57 deletions

View File

@ -0,0 +1,17 @@
import Foundation
extension FileManager {
/// Returns a URL for an exported items directory.
///
/// - Returns: A URL for storing an items export file.
///
func exportedItemsUrl() throws -> URL {
try url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
.appendingPathComponent("Exports", isDirectory: true)
}
}

View File

@ -36,7 +36,10 @@ public class ServiceContainer: Services {
/// The service used by the application to report non-fatal errors.
let errorReporter: ErrorReporter
/// The serviced used to perform app data migrations.
/// The service used to export items.
let exportItemsService: ExportItemsService
/// The service used to perform app data migrations.
let migrationService: MigrationService
/// The service used by the application for sharing data with other apps.
@ -63,6 +66,7 @@ public class ServiceContainer: Services {
/// - clientService: The service used by the application to handle encryption and decryption tasks.
/// - cryptographyService: The service used by the application to encrypt and decrypt items
/// - errorReporter: The service used by the application to report non-fatal errors.
/// - exportItemsService: The service to export items.
/// - migrationService: The service to do data migrations
/// - pasteboardService: The service used by the application for sharing data with other apps.
/// - stateService: The service for managing account state.
@ -77,6 +81,7 @@ public class ServiceContainer: Services {
cryptographyService: CryptographyService,
clientService: ClientService,
errorReporter: ErrorReporter,
exportItemsService: ExportItemsService,
migrationService: MigrationService,
pasteboardService: PasteboardService,
stateService: StateService,
@ -90,6 +95,7 @@ public class ServiceContainer: Services {
self.clientService = clientService
self.cryptographyService = cryptographyService
self.errorReporter = errorReporter
self.exportItemsService = exportItemsService
self.migrationService = migrationService
self.pasteboardService = pasteboardService
self.timeProvider = timeProvider
@ -110,17 +116,24 @@ public class ServiceContainer: Services {
let appSettingsStore = DefaultAppSettingsStore(
userDefaults: UserDefaults(suiteName: Bundle.main.groupIdentifier)!
)
let appIdService = AppIdService(appSettingStore: appSettingsStore)
let cameraService = DefaultCameraService()
let clientService = DefaultClientService()
let dataStore = DataStore(errorReporter: errorReporter)
let keychainService = DefaultKeychainService()
let keychainRepository = DefaultKeychainRepository(
appIdService: appIdService,
keychainService: keychainService
)
let stateService = DefaultStateService(appSettingsStore: appSettingsStore, dataStore: dataStore)
let stateService = DefaultStateService(
appSettingsStore: appSettingsStore,
dataStore: dataStore
)
let timeProvider = CurrentTime()
let cryptographyKeyService = CryptographyKeyService(
@ -146,14 +159,22 @@ public class ServiceContainer: Services {
let pasteboardService = DefaultPasteboardService(
errorReporter: errorReporter
)
let authenticatorItemService = DefaultAuthenticatorItemService(
authenticatorItemDataStore: dataStore
)
let authenticatorItemRepository = DefaultAuthenticatorItemRepository(
authenticatorItemService: authenticatorItemService,
cryptographyService: cryptographyService
)
let exportItemsService = DefaultExportItemsService(
authenticatorItemRepository: authenticatorItemRepository,
errorReporter: errorReporter,
timeProvider: timeProvider
)
self.init(
application: application,
appSettingsStore: appSettingsStore,
@ -162,6 +183,7 @@ public class ServiceContainer: Services {
cryptographyService: cryptographyService,
clientService: clientService,
errorReporter: errorReporter,
exportItemsService: exportItemsService,
migrationService: migrationService,
pasteboardService: pasteboardService,
stateService: stateService,

View File

@ -5,6 +5,7 @@ typealias Services = HasAuthenticatorItemRepository
& HasCameraService
& HasCryptographyService
& HasErrorReporter
& HasExportItemsService
& HasPasteboardService
& HasStateService
& HasTOTPService
@ -38,6 +39,13 @@ protocol HasErrorReporter {
var errorReporter: ErrorReporter { get }
}
/// Protocol for an object that provides an `ExportItemsService`.
///
protocol HasExportItemsService {
/// The service used to export items.
var exportItemsService: ExportItemsService { get }
}
/// Protocol for an object that provides a `PasteboardService`.
///
protocol HasPasteboardService {

View File

@ -12,6 +12,7 @@ extension ServiceContainer {
clientService: ClientService = MockClientService(),
cryptographyService: CryptographyService = MockCryptographyService(),
errorReporter: ErrorReporter = MockErrorReporter(),
exportItemsService: ExportItemsService = MockExportItemsService(),
migrationService: MigrationService = MockMigrationService(),
pasteboardService: PasteboardService = MockPasteboardService(),
stateService: StateService = MockStateService(),
@ -26,6 +27,7 @@ extension ServiceContainer {
cryptographyService: cryptographyService,
clientService: clientService,
errorReporter: errorReporter,
exportItemsService: exportItemsService,
migrationService: migrationService,
pasteboardService: pasteboardService,
stateService: stateService,

View File

@ -39,7 +39,7 @@ extension AuthenticatorItem {
/// Data model for an unencrypted item
///
public struct AuthenticatorItemView: Equatable, Sendable, Hashable {
public struct AuthenticatorItemView: Equatable, Sendable, Hashable, Codable {
let id: String
let name: String
let totpKey: String?

View File

@ -0,0 +1,16 @@
// MARK: - ExportFileType
/// An enum describing the format of an export file.
///
enum ExportFileType: Equatable {
/// A `.json` file type.
case json
/// The file extension type to use.
var fileExtension: String {
switch self {
case .json:
"json"
}
}
}

View File

@ -0,0 +1,157 @@
import Foundation
// MARK: - ExportItemsService
/// A service to export a list of items to a file.
///
protocol ExportItemsService: AnyObject {
/// Removes any temporarily export files.
func clearTemporaryFiles()
/// Creates the file contents for exported items of a given file type.
///
/// - Parameters:
/// - format: The format to use for export.
/// - Returns: A string representing the file content.
///
func exportFileContents(format: ExportFileType) async throws -> String
/// Generates a file name for the export file based on the current date, time, and specified type.
///
/// - Parameters:
/// - format: The format type to use to determine the extension.
/// - Returns: A string representing the file name.
///
func generateExportFileName(format: ExportFileType) -> String
/// Writes content to file with a provided name and returns a URL for the file.
///
/// - Parameters:
/// - fileName: The name of the file.
/// - fileContent: The content of the file.
/// - Returns: A URL for the file.
///
func writeToFile(name fileName: String, content fileContent: String) throws -> URL
}
extension ExportItemsService {
/// Export items with a given format.
///
/// - Parameters:
/// - format: The format of the exported file.
/// - Returns: A URL for the exported file.
///
func exportItems(format: ExportFileType) async throws -> URL {
// Export the items in the correct file content format.
let exportFileContents = try await exportFileContents(format: format)
// Generate the file name.
let fileName = generateExportFileName(format: format)
// Write the content to a file with the name.
let fileURL = try writeToFile(name: fileName, content: exportFileContents)
return fileURL
}
}
class DefaultExportItemsService: ExportItemsService {
// MARK: Properties
/// The item service.
private let authenticatorItemRepository: AuthenticatorItemRepository
/// The error reporter used by this service.
private let errorReporter: ErrorReporter
/// The time provider used by this service.
private let timeProvider: TimeProvider
// MARK: Initilzation
/// Initializes a new instance of a `DefaultExportItemsService`.
///
/// This service handles exporting items from local storage into a file.
///
/// - Parameters:
/// - authenticatorItemRepository: The service for getting items.
/// - cryptographyService: The service for cryptography tasks.
/// - errorReporter: The service for handling errors.
/// - timeProvider: The provider for current time, used in file naming and data timestamps.
///
init(
authenticatorItemRepository: AuthenticatorItemRepository,
errorReporter: ErrorReporter,
timeProvider: TimeProvider
) {
self.authenticatorItemRepository = authenticatorItemRepository
self.errorReporter = errorReporter
self.timeProvider = timeProvider
}
// MARK: Methods
func clearTemporaryFiles() {
Task {
do {
let url = try FileManager.default.exportedItemsUrl()
if FileManager.default.fileExists(atPath: url.path) {
try FileManager.default.removeItem(at: url)
}
} catch {
errorReporter.log(error: error)
}
}
}
func generateExportFileName(format: ExportFileType) -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyyMMddHHmmss"
let dateString = dateFormatter.string(from: timeProvider.presentTime)
return "bitwarden_authenticator_export_\(dateString).\(format.fileExtension)"
}
func exportFileContents(format: ExportFileType) async throws -> String {
let items = try await authenticatorItemRepository.fetchAllAuthenticatorItems()
let sortedItems = items.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
let encoder = JSONEncoder()
encoder.outputFormatting = .sortedKeys
let data = try encoder.encode(sortedItems)
guard let contents = String(data: data, encoding: .utf8) else {
throw ExportItemsError.unableToSerializeData
}
return contents
}
func writeToFile(
name fileName: String,
content fileContent: String
) throws -> URL {
// Get the exports directory.
let exportsDirectoryURL = try FileManager.default.exportedItemsUrl()
// Check if the directory exists, and create it if it doesn't.
if !FileManager.default.fileExists(atPath: exportsDirectoryURL.path) {
try FileManager.default.createDirectory(
at: exportsDirectoryURL,
withIntermediateDirectories: true,
attributes: nil
)
}
// Create the file URL.
let fileURL = exportsDirectoryURL.appendingPathComponent(fileName, isDirectory: false)
// Write the content to the file.
try fileContent.write(to: fileURL, atomically: true, encoding: .utf8)
return fileURL
}
}
// MARK: - ExportItemsError
enum ExportItemsError: Error {
case unableToSerializeData
}

View File

@ -0,0 +1,75 @@
import XCTest
@testable import AuthenticatorShared
// MARK: - ExportItemsServiceTests
final class ExportItemsServiceTests: AuthenticatorTestCase {
// MARK: Properties
var authItemRepository: MockAuthenticatorItemRepository!
var errorReporter: MockErrorReporter!
var timeProvider: MockTimeProvider!
var subject: ExportItemsService!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
authItemRepository = MockAuthenticatorItemRepository()
errorReporter = MockErrorReporter()
timeProvider = MockTimeProvider(
.mockTime(
.init(
year: 2024,
month: 02,
day: 14
)
)
)
subject = DefaultExportItemsService(
authenticatorItemRepository: authItemRepository,
errorReporter: errorReporter,
timeProvider: timeProvider
)
}
override func tearDown() {
super.tearDown()
subject = nil
}
// MARK: Tests
/// `exportFileContents` handles the JSON export
///
func test_exportFileContents_json() async throws {
let fileType = ExportFileType.json
let items = [
AuthenticatorItemView(
id: "One",
name: "Name",
totpKey: "otpauth://totp/Bitwarden:person@example.com?secret=EXAMPLE&issuer=Bitwarden"
),
]
authItemRepository.fetchAllAuthenticatorItemsResult = .success(items)
let exported = try await subject.exportFileContents(format: fileType)
let decoder = JSONDecoder()
let actual = try decoder.decode([AuthenticatorItemView].self, from: exported.data(using: .utf8)!)
XCTAssertEqual(items, actual)
}
/// `generateExportFileName` handles the JSON extension
///
func test_fileName_json() {
let fileType = ExportFileType.json
let expectedName = "bitwarden_authenticator_export_20240214000000.json"
let name = subject.generateExportFileName(format: fileType)
XCTAssertEqual(name, expectedName)
}
}

View File

@ -0,0 +1,31 @@
import Foundation
@testable import AuthenticatorShared
class MockExportItemsService: ExportItemsService {
var didClearFiles = false
var exportFileContentsFormat: ExportFileType?
var exportFileContentResult: Result<String, Error> = .failure(AuthenticatorTestError.example)
var mockFileName: String = "mockExport.json"
var writeToFileResult: Result<URL, Error> = .failure(AuthenticatorTestError.example)
func clearTemporaryFiles() {
didClearFiles = true
}
func exportFileContents(format: ExportFileType) async throws -> String {
exportFileContentsFormat = format
return try exportFileContentResult.get()
}
func generateExportFileName(format: ExportFileType) -> String {
mockFileName
}
func writeToFile(name fileName: String, content fileContent: String) throws -> URL {
try writeToFileResult.get()
}
}

View File

@ -10,7 +10,7 @@ final class StyleGuideFontTests: AuthenticatorTestCase {
func test_snapshot_styleGuideFont() {
for preview in StyleGuideFont_Previews._allPreviews {
assertSnapshots(
matching: preview.content,
of: preview.content,
as: [.defaultPortrait]
)
}
@ -20,7 +20,7 @@ final class StyleGuideFontTests: AuthenticatorTestCase {
func test_snapshot_styleGuideFont_largeText() {
for preview in StyleGuideFont_Previews._allPreviews {
assertSnapshots(
matching: preview.content,
of: preview.content,
as: [.defaultPortraitAX5]
)
}

View File

@ -901,3 +901,7 @@
"GetStarted" = "Get started";
"LaunchTutorial" = "Launch tutorial";
"Help" = "Help";
"ExportItemsConfirmationTitle" = "Confirm items export";
"ExportItems" = "Export items";
"ExportItemsWarning" = "This export contains your item data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it.";
"Export" = "Export";

View File

@ -9,7 +9,7 @@ class LoadingOverlayViewTests: AuthenticatorTestCase {
/// Test a snapshot of the loading overlay.
func test_snapshot_loadingOverlay() {
assertSnapshots(
matching: LoadingOverlayView(state: .init(title: "Loading...")),
of: LoadingOverlayView(state: .init(title: "Loading...")),
as: [.defaultPortrait, .defaultPortraitDark]
)
}

View File

@ -10,7 +10,7 @@ class SectionViewTests: AuthenticatorTestCase {
func test_snapshot_sectionView() {
for preview in SectionView_Previews._allPreviews {
assertSnapshots(
matching: preview.content,
of: preview.content,
as: [.defaultPortrait]
)
}

View File

@ -10,7 +10,7 @@ final class ToastViewTests: AuthenticatorTestCase {
func test_snapshot_toastView_previews() {
for preview in ToastView_Previews._allPreviews {
assertSnapshots(
matching: preview.content,
of: preview.content,
as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5]
)
}

View File

@ -72,22 +72,18 @@ extension Alert {
)
}
/// Confirm that the user wants to export their vault.
/// Confirm that the user wants to export their items.
///
/// - Parameters:
/// - encrypted: Whether the user is attempting to export their vault encrypted or not.
/// - action: The action performed when they select export vault.
/// - action: The action performed when they select export items.
/// - Returns: An alert confirming that the user wants to export their items unencrypted.
///
/// - Returns: An alert confirming that the user wants to export their vault unencrypted.
///
static func confirmExportVault(encrypted: Bool, action: @escaping () async -> Void) -> Alert {
static func confirmExportItems(action: @escaping () async -> Void) -> Alert {
Alert(
title: Localizations.exportVaultConfirmationTitle,
message: encrypted ?
(Localizations.encExportKeyWarning + .newLine + Localizations.encExportAccountWarning) :
Localizations.exportVaultWarning,
title: Localizations.exportItemsConfirmationTitle,
message: Localizations.exportItemsWarning,
alertActions: [
AlertAction(title: Localizations.exportVault, style: .default) { _ in await action() },
AlertAction(title: Localizations.exportItems, style: .default) { _ in await action() },
AlertAction(title: Localizations.cancel, style: .cancel),
]
)

View File

@ -59,25 +59,15 @@ class AlertSettingsTests: AuthenticatorTestCase {
XCTAssertEqual(subject.alertActions.last?.style, .default)
}
/// `confirmExportVault(encrypted:action:)` constructs an `Alert` with the title, message, and Yes and Export vault
/// buttons.
/// `confirmExportItems(action:)` constructs an `Alert`
/// with the title, message, and Yes and Export items buttons.
func test_confirmExportVault() {
var subject = Alert.confirmExportVault(encrypted: true) {}
let subject = Alert.confirmExportItems {}
XCTAssertEqual(subject.alertActions.count, 2)
XCTAssertEqual(subject.preferredStyle, .alert)
XCTAssertEqual(subject.title, Localizations.exportVaultConfirmationTitle)
XCTAssertEqual(
subject.message,
Localizations.encExportKeyWarning + .newLine + Localizations.encExportAccountWarning
)
subject = Alert.confirmExportVault(encrypted: false) {}
XCTAssertEqual(subject.alertActions.count, 2)
XCTAssertEqual(subject.preferredStyle, .alert)
XCTAssertEqual(subject.title, Localizations.exportVaultConfirmationTitle)
XCTAssertEqual(subject.message, Localizations.exportVaultWarning)
XCTAssertEqual(subject.title, Localizations.exportItemsConfirmationTitle)
XCTAssertEqual(subject.message, Localizations.exportItemsWarning)
}
/// `displayFingerprintPhraseAlert(encrypted:action:)` constructs an `Alert`

View File

@ -47,18 +47,11 @@ class SelectLanguageViewTests: AuthenticatorTestCase {
// MARK: Snapshots
/// Test that the default view renders correctly.
func test_snapshot_default() {
assertSnapshot(of: subject.navStackWrapped, as: .defaultPortrait)
}
/// Test that the default view renders correctly.
func test_snapshot_default_dark() {
assertSnapshot(of: subject.navStackWrapped, as: .defaultPortraitDark)
}
/// Test that the default view renders correctly.
func test_snapshot_default_large() {
assertSnapshot(of: subject.navStackWrapped, as: .tallPortraitAX5())
/// Test that the view renders correctly.
func test_viewRender() {
assertSnapshots(
of: subject.navStackWrapped,
as: [.defaultPortrait, .defaultPortraitDark, .tallPortraitAX5()]
)
}
}

View File

@ -7,6 +7,9 @@ enum SettingsAction: Equatable {
/// The url has been opened so clear the value in the state.
case clearURL
/// The export items button was tapped.
case exportItemsTapped
/// The help center button was tapped.
case helpCenterTapped

View File

@ -6,6 +6,7 @@ final class SettingsProcessor: StateProcessor<SettingsState, SettingsAction, Set
// MARK: Types
typealias Services = HasErrorReporter
& HasExportItemsService
& HasPasteboardService
& HasStateService
@ -55,6 +56,8 @@ final class SettingsProcessor: StateProcessor<SettingsState, SettingsAction, Set
}
case .clearURL:
state.url = nil
case .exportItemsTapped:
confirmExportItems()
case .helpCenterTapped:
state.url = ExternalLinksConstants.helpAndFeedback
case .languageTapped:
@ -75,6 +78,18 @@ final class SettingsProcessor: StateProcessor<SettingsState, SettingsAction, Set
// MARK: - Private Methods
/// Shows the alert to confirm the items export.
private func confirmExportItems() {
coordinator.showAlert(.confirmExportItems {
do {
let fileUrl = try await self.services.exportItemsService.exportItems(format: .json)
self.coordinator.navigate(to: .shareExportedItems(fileUrl))
} catch {
self.services.errorReporter.log(error: error)
}
})
}
/// Prepare the text to be copied.
private func handleVersionTapped() {
// Copy the copyright text followed by the version info.

View File

@ -74,6 +74,13 @@ struct SettingsView: View {
/// The settings items.
private var settingsItems: some View {
VStack(spacing: 0) {
SectionView(Localizations.vault) {
SettingsListItem(Localizations.export) {
store.send(.exportItemsTapped)
}
}
.padding(.bottom, 32)
SectionView(Localizations.appearance) {
language
theme

View File

@ -8,6 +8,9 @@ import XCTest
class SettingsViewTests: AuthenticatorTestCase {
// MARK: Properties
let copyrightText = "© Bitwarden Inc. 2015-2024"
let version = "Version: 1.0.0 (1)"
var processor: MockProcessor<SettingsState, SettingsAction, SettingsEffect>!
var subject: SettingsView!
@ -16,7 +19,7 @@ class SettingsViewTests: AuthenticatorTestCase {
override func setUp() {
super.setUp()
processor = MockProcessor(state: SettingsState())
processor = MockProcessor(state: SettingsState(copyrightText: copyrightText, version: version))
let store = Store(processor: processor)
subject = SettingsView(store: store)
@ -31,8 +34,61 @@ class SettingsViewTests: AuthenticatorTestCase {
// MARK: Tests
/// Updating the value of the app theme sends the `.appThemeChanged()` action.
func test_appThemeChanged_updateValue() throws {
processor.state.appTheme = .light
let menuField = try subject.inspect().find(settingsMenuField: Localizations.theme)
try menuField.select(newValue: AppTheme.dark)
XCTAssertEqual(processor.dispatchedActions.last, .appThemeChanged(.dark))
}
/// Tapping the export button dispatches the `.exportItemsTapped` action.
func test_exportButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.export)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .exportItemsTapped)
}
/// Tapping the help center button dispatches the `.helpCenterTapped` action.
func test_helpCenterButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.bitwardenHelpCenter)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .helpCenterTapped)
}
/// Tapping the language button dispatches the `.languageTapped` action.
func test_languageButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.language)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .languageTapped)
}
/// Tapping the privacy policy button dispatches the `.privacyPolicyTapped` action.
func test_privacyPolicyButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.privacyPolicy)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .privacyPolicyTapped)
}
/// Tapping the tutorial button dispatches the `.tutorialTapped` action.
func test_tutorialButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.launchTutorial)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .tutorialTapped)
}
/// Tapping the version button dispatches the `.versionTapped` action.
func test_versionButton_tap() throws {
let button = try subject.inspect().find(button: version)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .versionTapped)
}
/// Tests the view renders correctly.
func test_viewRender() {
assertSnapshot(of: subject, as: .defaultPortrait)
assertSnapshots(
of: subject,
as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5]
)
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

View File

@ -12,6 +12,7 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
typealias Module = TutorialModule
typealias Services = HasErrorReporter
& HasExportItemsService
& HasPasteboardService
& HasStateService
& HasTimeProvider
@ -61,6 +62,8 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
showSelectLanguage(currentLanguage: currentLanguage, delegate: context as? SelectLanguageDelegate)
case .settings:
showSettings()
case let .shareExportedItems(fileUrl):
showExportedItemsUrl(fileUrl)
case .tutorial:
showTutorial()
}
@ -72,6 +75,13 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
// MARK: Private Methods
/// Presents an activity controller for an exported items file URL.
///
private func showExportedItemsUrl(_ fileUrl: URL) {
let activityVC = UIActivityViewController(activityItems: [fileUrl], applicationActivities: nil)
stackNavigator?.present(activityVC)
}
/// Shows the select language screen.
///
private func showSelectLanguage(currentLanguage: LanguageOption, delegate: SelectLanguageDelegate?) {

View File

@ -16,6 +16,9 @@ public enum SettingsRoute: Equatable, Hashable {
/// A route to the settings screen.
case settings
/// A route to the share sheet for an exported items URL.
case shareExportedItems(URL)
/// A route to show the tutorial.
case tutorial
}

View File

@ -42,7 +42,7 @@ class ItemListViewTests: AuthenticatorTestCase {
func test_snapshot_ItemListView_previews() {
for preview in ItemListView_Previews._allPreviews {
assertSnapshots(
matching: preview.content,
of: preview.content,
as: [.defaultPortrait]
)
}

View File

@ -68,7 +68,7 @@ class ManualEntryViewTests: AuthenticatorTestCase {
/// Test a snapshot of the ProfileSwitcherView empty state.
func test_snapshot_manualEntryView_empty() {
assertSnapshot(
matching: ManualEntryView_Previews.empty,
of: ManualEntryView_Previews.empty,
as: .defaultPortrait
)
}
@ -76,7 +76,7 @@ class ManualEntryViewTests: AuthenticatorTestCase {
/// Test a snapshot of the ProfileSwitcherView empty state.
func test_snapshot_manualEntryView_empty_landscape() {
assertSnapshot(
matching: ManualEntryView_Previews.empty,
of: ManualEntryView_Previews.empty,
as: .defaultLandscape
)
}
@ -84,7 +84,7 @@ class ManualEntryViewTests: AuthenticatorTestCase {
/// Test a snapshot of the ProfileSwitcherView in dark mode.
func test_snapshot_manualEntryView_text_dark() {
assertSnapshot(
matching: ManualEntryView_Previews.textAdded,
of: ManualEntryView_Previews.textAdded,
as: .defaultPortraitDark
)
}
@ -92,7 +92,7 @@ class ManualEntryViewTests: AuthenticatorTestCase {
/// Test a snapshot of the ProfileSwitcherView with large text.
func test_snapshot_manualEntryView_text_largeText() {
assertSnapshot(
matching: ManualEntryView_Previews.textAdded,
of: ManualEntryView_Previews.textAdded,
as: .tallPortraitAX5(heightMultiple: 1.75)
)
}
@ -100,7 +100,7 @@ class ManualEntryViewTests: AuthenticatorTestCase {
/// Test a snapshot of the ProfileSwitcherView in light mode.
func test_snapshot_manualEntryView_text_light() {
assertSnapshot(
matching: ManualEntryView_Previews.textAdded,
of: ManualEntryView_Previews.textAdded,
as: .defaultPortrait
)
}

View File

@ -46,7 +46,7 @@ class ScanCodeViewTests: AuthenticatorTestCase {
func test_snapshot_scanCodeView_previews() {
for preview in ScanCodeView_Previews._allPreviews {
assertSnapshots(
matching: preview.content,
of: preview.content,
as: [
.defaultPortrait,
.defaultLandscape,

View File

@ -39,6 +39,6 @@ final class CircularProgressShapeTests: AuthenticatorTestCase {
.frame(width: 30, height: 30)
}
assertSnapshot(matching: stack, as: .portrait(heightMultiple: 0.1))
assertSnapshot(of: stack, as: .portrait(heightMultiple: 0.1))
}
}