Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import XCTest
import Testing

@testable import shared_preferences_foundation

Expand All @@ -12,104 +12,104 @@ import XCTest
import FlutterMacOS
#endif

class RunnerTests: XCTestCase {
// The shared preferences API is thread-safe.
// Serially mutate shared preferences
// so tests don't stomp on each other.
@Suite(.serialized)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lots of test failures when these tests run in parallel. For example, there's no locking in the update methods like:

func clear(allowList: [String]?, options: SharedPreferencesPigeonOptions) throws {
let defaults = try SharedPreferencesPlugin.getUserDefaults(options: options)
if let allowList = allowList {
for (key) in allowList {
defaults.removeObject(forKey: key)
}
} else {
for key in defaults.dictionaryRepresentation().keys {
defaults.removeObject(forKey: key)
}
}
}

Should probably be migrated to actor?

Surprisingly I don't see any open issues that look related.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should probably be migrated to actor?

We now have toooo many options!

  1. Actor
  2. GCD
  3. the new Synchronization framework introduced last year
  4. Locks (os_unfair_lock is likely the most performant)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth adding a TODO to remove .serialized?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Come to think of it, this isn't actually a bug. The defaults themselves are a threadsafe API, so if calling code, like these tests, are relying on another thread not mutating the defaults, then it would up to that calling code to handle it. I actually now think @Suite(.serialized) is correct. I'll add a comment.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was going to say something like this. My understanding when I wrote this code is that behavior is expected. I can see changing it though, if we wanted to make the plugin work that way instead.

Copy link
Member Author

@jmagman jmagman Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's still a few pieces here that look a little thread unsafe, for example:


That's looping over a collection while mutating it, which is generally not a good pattern. There's no guarantee the defaults wouldn't be added by another thread while it's being cleared. correction: no it's not, it's looping over the dictionary representation.
But it's way less dire than I made it out to be in the initial description. Sorry.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had me worried there for a second :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there are others, I'd be happy to hear about them though. I'm always happy to learn from my mistakes.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the noise and panic, @tarrinneal 🙂

struct RunnerTests {
let testKey = "foo"
let testKeyTwo = "baz"
let testValue = "bar"

// Legacy system tests.

let prefixes: [String] = ["aPrefix", ""]

func testSetAndGet() throws {
for aPrefix in prefixes {
let plugin = LegacySharedPreferencesPlugin()

plugin.setBool(key: "\(aPrefix)aBool", value: true)
plugin.setDouble(key: "\(aPrefix)aDouble", value: 3.14)
plugin.setValue(key: "\(aPrefix)anInt", value: 42)
plugin.setValue(key: "\(aPrefix)aString", value: "hello world")
plugin.setValue(key: "\(aPrefix)aStringList", value: ["hello", "world"])

let storedValues = plugin.getAll(prefix: aPrefix, allowList: nil)
XCTAssertEqual(storedValues["\(aPrefix)aBool"] as? Bool, true)
XCTAssertEqual(storedValues["\(aPrefix)aDouble"] as! Double, 3.14, accuracy: 0.0001)
XCTAssertEqual(storedValues["\(aPrefix)anInt"] as? Int, 42)
XCTAssertEqual(storedValues["\(aPrefix)aString"] as? String, "hello world")
XCTAssertEqual(storedValues["\(aPrefix)aStringList"] as? [String], ["hello", "world"])
}
@Test(arguments: ["aPrefix", ""])
func setAndGet(prefix: String) throws {
let plugin = LegacySharedPreferencesPlugin()

plugin.setBool(key: "\(prefix)aBool", value: true)
plugin.setDouble(key: "\(prefix)aDouble", value: 3.14)
plugin.setValue(key: "\(prefix)anInt", value: 42)
plugin.setValue(key: "\(prefix)aString", value: "hello world")
plugin.setValue(key: "\(prefix)aStringList", value: ["hello", "world"])

let storedValues = plugin.getAll(prefix: prefix, allowList: nil)
#expect(storedValues["\(prefix)aBool"] as? Bool == true)

let doubleValue = try #require(storedValues["\(prefix)aDouble"] as? Double)
#expect(abs(doubleValue - 3.14) < 0.0001)

#expect(storedValues["\(prefix)anInt"] as? Int == 42)
#expect(storedValues["\(prefix)aString"] as? String == "hello world")
#expect(storedValues["\(prefix)aStringList"] as? [String] == ["hello", "world"])
}

func testGetWithAllowList() throws {
for aPrefix in prefixes {
let plugin = LegacySharedPreferencesPlugin()

plugin.setBool(key: "\(aPrefix)aBool", value: true)
plugin.setDouble(key: "\(aPrefix)aDouble", value: 3.14)
plugin.setValue(key: "\(aPrefix)anInt", value: 42)
plugin.setValue(key: "\(aPrefix)aString", value: "hello world")
plugin.setValue(key: "\(aPrefix)aStringList", value: ["hello", "world"])

let storedValues = plugin.getAll(prefix: aPrefix, allowList: ["\(aPrefix)aBool"])
XCTAssertEqual(storedValues["\(aPrefix)aBool"] as? Bool, true)
XCTAssertNil(storedValues["\(aPrefix)aDouble"] ?? nil)
XCTAssertNil(storedValues["\(aPrefix)anInt"] ?? nil)
XCTAssertNil(storedValues["\(aPrefix)aString"] ?? nil)
XCTAssertNil(storedValues["\(aPrefix)aStringList"] ?? nil)
}
@Test(arguments: ["aPrefix", ""])
func getWithAllowList(prefix: String) throws {
let plugin = LegacySharedPreferencesPlugin()

plugin.setBool(key: "\(prefix)aBool", value: true)
plugin.setDouble(key: "\(prefix)aDouble", value: 3.14)
plugin.setValue(key: "\(prefix)anInt", value: 42)
plugin.setValue(key: "\(prefix)aString", value: "hello world")
plugin.setValue(key: "\(prefix)aStringList", value: ["hello", "world"])

let storedValues = plugin.getAll(prefix: prefix, allowList: ["\(prefix)aBool"])
#expect(storedValues["\(prefix)aBool"] as? Bool == true)
#expect(storedValues["\(prefix)aDouble"] == nil)
#expect(storedValues["\(prefix)anInt"] == nil)
#expect(storedValues["\(prefix)aString"] == nil)
#expect(storedValues["\(prefix)aStringList"] == nil)
}

func testRemove() throws {
for aPrefix in prefixes {
let plugin = LegacySharedPreferencesPlugin()
let testKey = "\(aPrefix)\(testKey)"
plugin.setValue(key: testKey, value: 42)
@Test(arguments: ["aPrefix", ""])
func remove(prefix: String) throws {
let plugin = LegacySharedPreferencesPlugin()
let key = "\(prefix)\(testKey)"
plugin.setValue(key: key, value: 42)

// Make sure there is something to remove, so the test can't pass due to a set failure.
let preRemovalValues = plugin.getAll(prefix: aPrefix, allowList: nil)
XCTAssertEqual(preRemovalValues[testKey] as? Int, 42)
// Make sure there is something to remove, so the test can't pass due to a set failure.
let preRemovalValues = plugin.getAll(prefix: prefix, allowList: nil)
#expect(preRemovalValues[key] as? Int == 42)

// Then verify that removing it works.
plugin.remove(key: testKey)
// Then verify that removing it works.
plugin.remove(key: key)

let finalValues = plugin.getAll(prefix: aPrefix, allowList: nil)
XCTAssertNil(finalValues[testKey] as Any?)
}
let finalValues = plugin.getAll(prefix: prefix, allowList: nil)
#expect(finalValues[key] == nil)
}

func testClearWithNoAllowlist() throws {
for aPrefix in prefixes {
let plugin = LegacySharedPreferencesPlugin()
let testKey = "\(aPrefix)\(testKey)"
plugin.setValue(key: testKey, value: 42)
@Test(arguments: ["aPrefix", ""])
func clearWithNoAllowlist(prefix: String) throws {
let plugin = LegacySharedPreferencesPlugin()
let key = "\(prefix)\(testKey)"
plugin.setValue(key: key, value: 42)

// Make sure there is something to clear, so the test can't pass due to a set failure.
let preRemovalValues = plugin.getAll(prefix: aPrefix, allowList: nil)
XCTAssertEqual(preRemovalValues[testKey] as? Int, 42)
// Make sure there is something to clear, so the test can't pass due to a set failure.
let preRemovalValues = plugin.getAll(prefix: prefix, allowList: nil)
#expect(preRemovalValues[key] as? Int == 42)

// Then verify that clearing works.
plugin.clear(prefix: aPrefix, allowList: nil)
// Then verify that clearing works.
#expect(plugin.clear(prefix: prefix, allowList: nil) == true)

let finalValues = plugin.getAll(prefix: aPrefix, allowList: nil)
XCTAssertNil(finalValues[testKey] as Any?)
}
let finalValues = plugin.getAll(prefix: prefix, allowList: nil)
#expect(finalValues[key] == nil)
}

func testClearWithAllowlist() throws {
for aPrefix in prefixes {
let plugin = LegacySharedPreferencesPlugin()
let testKey = "\(aPrefix)\(testKey)"
plugin.setValue(key: testKey, value: 42)
@Test(arguments: ["aPrefix", ""])
func clearWithAllowlist(prefix: String) throws {
let plugin = LegacySharedPreferencesPlugin()
let key = "\(prefix)\(testKey)"
plugin.setValue(key: key, value: 42)

// Make sure there is something to clear, so the test can't pass due to a set failure.
let preRemovalValues = plugin.getAll(prefix: aPrefix, allowList: nil)
XCTAssertEqual(preRemovalValues[testKey] as? Int, 42)
// Make sure there is something to clear, so the test can't pass due to a set failure.
let preRemovalValues = plugin.getAll(prefix: prefix, allowList: nil)
#expect(preRemovalValues[key] as? Int == 42)

plugin.clear(prefix: aPrefix, allowList: ["\(aPrefix)\(testKeyTwo)"])
#expect(plugin.clear(prefix: prefix, allowList: ["\(prefix)\(testKeyTwo)"]) == true)

let finalValues = plugin.getAll(prefix: aPrefix, allowList: nil)
XCTAssertEqual(finalValues[testKey] as? Int, 42)
}
let finalValues = plugin.getAll(prefix: prefix, allowList: nil)
#expect(finalValues[key] as? Int == 42)
}

// Async system tests.
Expand All @@ -118,7 +118,7 @@ class RunnerTests: XCTestCase {
let optionsWithSuiteName = SharedPreferencesPigeonOptions(
suiteName: "group.example.sharedPreferencesFoundationExample")

func testAsyncSetAndGet() throws {
@Test func asyncSetAndGet() throws {
let plugin = SharedPreferencesPlugin()

try plugin.set(key: "aBool", value: true, options: emptyOptions)
Expand All @@ -127,18 +127,19 @@ class RunnerTests: XCTestCase {
try plugin.set(key: "aString", value: "hello world", options: emptyOptions)
try plugin.set(key: "aStringList", value: ["hello", "world"], options: emptyOptions)

XCTAssertEqual(((try plugin.getValue(key: "aBool", options: emptyOptions)) != nil), true)
XCTAssertEqual(
try plugin.getValue(key: "aDouble", options: emptyOptions) as! Double, 3.14, accuracy: 0.0001)
XCTAssertEqual(try plugin.getValue(key: "anInt", options: emptyOptions) as! Int, 42)
XCTAssertEqual(
try plugin.getValue(key: "aString", options: emptyOptions) as! String, "hello world")
XCTAssertEqual(
try plugin.getValue(key: "aStringList", options: emptyOptions) as! [String],
["hello", "world"])
#expect((try plugin.getValue(key: "aBool", options: emptyOptions)) != nil)
let doubleVal = try #require(plugin.getValue(key: "aDouble", options: emptyOptions) as? Double)
#expect(abs(doubleVal - 3.14) < 0.0001)

#expect(try plugin.getValue(key: "anInt", options: emptyOptions) as? Int == 42)
#expect(try plugin.getValue(key: "aString", options: emptyOptions) as? String == "hello world")
#expect(
try plugin.getValue(key: "aStringList", options: emptyOptions) as? [String] == [
"hello", "world",
])
}

func testAsyncGetAll() throws {
@Test func asyncGetAll() throws {
let plugin = SharedPreferencesPlugin()

try plugin.set(key: "aBool", value: true, options: emptyOptions)
Expand All @@ -148,28 +149,30 @@ class RunnerTests: XCTestCase {
try plugin.set(key: "aStringList", value: ["hello", "world"], options: emptyOptions)

let storedValues = try plugin.getAll(allowList: nil, options: emptyOptions)
XCTAssertEqual(storedValues["aBool"] as? Bool, true)
XCTAssertEqual(storedValues["aDouble"] as! Double, 3.14, accuracy: 0.0001)
XCTAssertEqual(storedValues["anInt"] as? Int, 42)
XCTAssertEqual(storedValues["aString"] as? String, "hello world")
XCTAssertEqual(storedValues["aStringList"] as? [String], ["hello", "world"])
#expect(storedValues["aBool"] as? Bool == true)

let doubleVal = try #require(storedValues["aDouble"] as? Double)
#expect(abs(doubleVal - 3.14) < 0.0001)

#expect(storedValues["anInt"] as? Int == 42)
#expect(storedValues["aString"] as? String == "hello world")
#expect(storedValues["aStringList"] as? [String] == ["hello", "world"])
}

func testAsyncGetAllWithAndWithoutSuiteName() throws {
@Test func asyncGetAllWithAndWithoutSuiteName() throws {
let plugin = SharedPreferencesPlugin()

try plugin.set(key: "aKey", value: "hello world", options: emptyOptions)
try plugin.set(key: "aKeySuite", value: "hello world with suite", options: optionsWithSuiteName)

let storedValues = try plugin.getAll(allowList: nil, options: emptyOptions)
XCTAssertEqual(storedValues["aKey"] as? String, "hello world")
#expect(storedValues["aKey"] as? String == "hello world")

let storedValuesWithGroup = try plugin.getAll(allowList: nil, options: optionsWithSuiteName)
XCTAssertEqual(storedValuesWithGroup["aKeySuite"] as? String, "hello world with suite")
#expect(storedValuesWithGroup["aKeySuite"] as? String == "hello world with suite")
}

func testAsyncGetAllWithAllowList() throws {
@Test func asyncGetAllWithAllowList() throws {
let plugin = SharedPreferencesPlugin()

try plugin.set(key: "aBool", value: true, options: emptyOptions)
Expand All @@ -179,61 +182,58 @@ class RunnerTests: XCTestCase {
try plugin.set(key: "aStringList", value: ["hello", "world"], options: emptyOptions)

let storedValues = try plugin.getAll(allowList: ["aBool"], options: emptyOptions)
XCTAssertEqual(storedValues["aBool"] as? Bool, true)
XCTAssertNil(storedValues["aDouble"] ?? nil)
XCTAssertNil(storedValues["anInt"] ?? nil)
XCTAssertNil(storedValues["aString"] ?? nil)
XCTAssertNil(storedValues["aStringList"] ?? nil)

#expect(storedValues["aBool"] as? Bool == true)
#expect(storedValues["aDouble"] == nil)
#expect(storedValues["anInt"] == nil)
#expect(storedValues["aString"] == nil)
#expect(storedValues["aStringList"] == nil)
}

func testAsyncRemove() throws {
@Test func asyncRemove() throws {
let plugin = SharedPreferencesPlugin()
try plugin.set(key: testKey, value: testValue, options: emptyOptions)

// Make sure there is something to remove, so the test can't pass due to a set failure.
let preRemovalValue = try plugin.getValue(key: testKey, options: emptyOptions) as! String
XCTAssertEqual(preRemovalValue, testValue)
let preRemovalValue = try plugin.getValue(key: testKey, options: emptyOptions) as? String
#expect(preRemovalValue == testValue)

// Then verify that removing it works.
try plugin.remove(key: testKey, options: emptyOptions)

let finalValue = try plugin.getValue(key: testKey, options: emptyOptions)
XCTAssertNil(finalValue)

#expect(finalValue == nil)
}

func testAsyncClearWithNoAllowlist() throws {
@Test func asyncClearWithNoAllowlist() throws {
let plugin = SharedPreferencesPlugin()
try plugin.set(key: testKey, value: testValue, options: emptyOptions)

// Make sure there is something to remove, so the test can't pass due to a set failure.
let preRemovalValue = try plugin.getValue(key: testKey, options: emptyOptions) as! String
XCTAssertEqual(preRemovalValue, testValue)
let preRemovalValue = try plugin.getValue(key: testKey, options: emptyOptions) as? String
#expect(preRemovalValue == testValue)

// Then verify that clearing works.
try plugin.clear(allowList: nil, options: emptyOptions)

let finalValue = try plugin.getValue(key: testKey, options: emptyOptions)
XCTAssertNil(finalValue)

#expect(finalValue == nil)
}

func testAsyncClearWithAllowlist() throws {
@Test func asyncClearWithAllowlist() throws {
let plugin = SharedPreferencesPlugin()

try plugin.set(key: testKey, value: testValue, options: emptyOptions)
try plugin.set(key: testKeyTwo, value: testValue, options: emptyOptions)

// Make sure there is something to clear, so the test can't pass due to a set failure.
let preRemovalValue = try plugin.getValue(key: testKey, options: emptyOptions) as! String
XCTAssertEqual(preRemovalValue, testValue)
let preRemovalValue = try plugin.getValue(key: testKey, options: emptyOptions) as? String
#expect(preRemovalValue == testValue)

try plugin.clear(allowList: [testKey], options: emptyOptions)

let finalValueNil = try plugin.getValue(key: testKey, options: emptyOptions)
XCTAssertNil(finalValueNil)
let finalValueNotNil = try plugin.getValue(key: testKeyTwo, options: emptyOptions) as! String
XCTAssertEqual(finalValueNotNil, testValue)
#expect(finalValueNil == nil)
let finalValueNotNil = try plugin.getValue(key: testKeyTwo, options: emptyOptions) as? String
#expect(finalValueNotNil == testValue)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,7 @@
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
SWIFT_VERSION = 6.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
Expand All @@ -501,7 +501,7 @@
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.sharedPreferencesFoundationExample.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
SWIFT_VERSION = 6.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
Expand All @@ -517,7 +517,7 @@
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.sharedPreferencesFoundationExample.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
SWIFT_VERSION = 6.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
Expand Down
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Xcode is expecting this directory:
Image

macOS gets away with it because there's a (probably unnecessary) Info.plist in the equivalent directory:
https://github.com/flutter/packages/tree/fb3e9081ca58b1cb3e54b8bd471c57116a72585e/packages/shared_preferences/shared_preferences_foundation/example/macos/RunnerTests

Empty file.
Loading
Loading