diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 88fcaef9..fa2a16bd 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -10,3 +10,13 @@ ## Requires SpacetimeDB PRs *List any PRs here that are required for this SDK change to work* + +## Testsuite +*If you would like to run against a specific SpacetimeDB branch in the testsuite, specify that here. This can be a branch name or a link to a PR.* + +SpacetimeDB branch name: master + +## Testing +*Write instructions for a test that you performed for this PR* + +- [ ] Describe a test for this PR that you have completed diff --git a/.github/workflows/unity-test.yml b/.github/workflows/unity-test.yml new file mode 100644 index 00000000..48f9df81 --- /dev/null +++ b/.github/workflows/unity-test.yml @@ -0,0 +1,119 @@ +name: Unity Test Suite + +on: + push: + branches: + - staging + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Grab the branch name from the PR description. If it's not found, master will be used instead. + - name: Extract SpacetimeDB branch name or PR link from PR description + id: extract-branch + if: github.event_name == 'pull_request' + run: | + description=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + ${{ github.event.pull_request.url }} | jq -r '.body') + + # Check if description contains a branch name or a PR link + branch_or_pr=$(echo "$description" | grep -oP '(?<=SpacetimeDB branch name:\s).+') + echo "Branch or PR found: $branch_or_pr" + + if [[ -z "$branch_or_pr" ]]; then + branch="master" + elif [[ "$branch_or_pr" =~ ^https://github.com/.*/pull/[0-9]+$ ]]; then + # If it's a PR link, extract the branch name from the PR + pr_number=$(echo "$branch_or_pr" | grep -oP '[0-9]+$') + branch=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + https://api.github.com/repos/clockworklabs/SpacetimeDB/pulls/$pr_number | jq -r '.head.ref') + else + # It's already a branch name + branch="$branch_or_pr" + fi + + echo "branch=$branch" >> $GITHUB_OUTPUT + echo "Final branch name: $branch" + + - name: Replace com.clockworklabs.spacetimedbsdk in manifest.json + run: | + # Get the branch name from the environment variable + branch_name="${{ github.head_ref }}" + # Replace any reference to com.clockworklabs.spacetimedbsdk with the correct GitHub URL using the current branch + sed -i "s|\"com.clockworklabs.spacetimedbsdk\":.*|\"com.clockworklabs.spacetimedbsdk\": \"https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk.git#$branch_name\",|" unity-tests~/client/Packages/manifest.json + + cat unity-tests~/client/Packages/manifest.json + - name: Install Rust toolchain + run: | + curl https://sh.rustup.rs -sSf | sh -s -- -y + source $HOME/.cargo/env + rustup install stable + rustup default stable + + - name: Install SpacetimeDB CLI from specific branch + run: | + cd unity-tests~ + git clone https://github.com/clockworklabs/SpacetimeDB.git + cd SpacetimeDB + # Sanitize the branch name by trimming any newlines or spaces + branch_name=$(echo "${{ steps.extract-branch.outputs.branch }}" | tr -d '[:space:]') + # If the branch name is not found, default to master + if [ -z "$branch_name" ]; then + branch_name="master" + fi + git checkout "$branch_name" + echo "Checked out branch: $branch_name" + cargo build --release -p spacetimedb-cli + sudo mv target/release/spacetime /usr/bin/spacetime + + - name: Generate client bindings + run: | + cd unity-tests~/server + bash ./generate.sh -y + + - name: Start SpacetimeDB + run: | + spacetime start & + disown + + - uses: actions/cache@v3 + with: + path: | + unity-tests~/server/target + ~/.cargo + key: server-target-SpacetimeDBUnityTestsuite-Linux-x86-${{ runner.os }}-${{ hashFiles('unity-tests~/server/Cargo.lock') }} + restore-keys: | + server-target-SpacetimeDBUnityTestsuite-Linux-x86-${{ runner.os }}- + server-target-SpacetimeDBUnityTestsuite- + + - name: Publish module to SpacetimeDB + run: | + cd unity-tests~/server + bash ./publish.sh + + - uses: actions/cache@v3 + with: + path: unity-tests~/client/Library + key: Library-SpacetimeDBUnityTestsuite-Linux-x86 + restore-keys: | + Library-SpacetimeDBUnityTestsuite- + Library- + + - name: Set up Unity + uses: game-ci/unity-test-runner@v4 + with: + unityVersion: 2022.3.32f1 # Adjust Unity version to a valid tag + projectPath: unity-tests~/client # Path to the Unity project subdirectory + githubToken: ${{ secrets.GITHUB_TOKEN }} + testMode: playmode + useHostNetwork: true + env: + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + diff --git a/.gitignore b/.gitignore index 42502a20..62656efe 100644 --- a/.gitignore +++ b/.gitignore @@ -75,4 +75,6 @@ obj~ # This is used for local paths to SpacetimeDB packages. /nuget.config /nuget.config.meta + +# JetBrains .idea/ diff --git a/unity-tests~/README.md b/unity-tests~/README.md new file mode 100644 index 00000000..83ea299c --- /dev/null +++ b/unity-tests~/README.md @@ -0,0 +1,35 @@ +## Unity Testsuite + +This testsuite is designed to test the SDK against a real Unity project. This project was initially branched off of the [SpacetimeDBCircleGame](https://github.com/clockworklabs/SpacetimeDBCircleGame) + +### Running Locally + +1. clone this repository +2. Open UnityHub +3. Make sure you have a valid Unity license +4. Add -> `com.clockworklabs.spacetimedbsdk/unity-tests/client` +5. Open the project called `client` at the top of your project list + +Now, while that's loading you can get SpacetimeDB setup. The easiest thing to do will be this: +``` +# cd into your unity-tests directory +cd com.clockworklabs.spacetimedbsdk/unity-tests +# clone a new instance of SpacetimeDB within this directory +git clone ssh://git@github.com/clockworklabs/SpacetimeDB +cd SpacetimeDB +git checkout a-branch-I-want-to-test-against +# You probably want to install this version of the CLI for generation + publishing +cargo install --path ./crates/cli +# start spacetimedb, Boppy recommends starting this in a separate terminal window +spacetime start & + +# generate bindings +bash server/generate.sh + +# publish module +bash server/publish.sh +``` + +After you've done this you can open `Window -> General -> Test Runner` which will open the test runner interface. If you look in `Play Mode Tests` you should see some tests you can run. You can run tests individually, run selected tests or run all tests. + +*NOTE: The Unity project that you open is called "client" and it has a local reference to the unity package so if you make changes to the unity package code it will update automatically in the editor.* diff --git a/unity-tests~/client/Assets/PlayModeTests.meta b/unity-tests~/client/Assets/PlayModeTests.meta new file mode 100644 index 00000000..87661dc4 --- /dev/null +++ b/unity-tests~/client/Assets/PlayModeTests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 06e64d41fe8564be38ab76f4b907e50e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity-tests~/client/Assets/PlayModeTests/PlayModeExampleTest.cs b/unity-tests~/client/Assets/PlayModeTests/PlayModeExampleTest.cs new file mode 100644 index 00000000..bc1cbc69 --- /dev/null +++ b/unity-tests~/client/Assets/PlayModeTests/PlayModeExampleTest.cs @@ -0,0 +1,284 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Net.Sockets; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using NUnit.Framework; +using SpacetimeDB; +using SpacetimeDB.Types; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; +using Random = UnityEngine.Random; +using Vector2 = SpacetimeDB.Types.Vector2; + +public class PlayModeExampleTest +{ + // [UnityTest] - This won't work until we have reconnections + public IEnumerator SimpleConnectionTest() + { + PlayerPrefs.DeleteAll(); + var connected = false; + var conn = DbConnection.Builder().OnConnect((a, b, c) => + { + connected = true; + }).OnConnectError((_, _) => + { + Debug.Assert(false, "Connection failed!"); + }).WithUri("http://127.0.0.1:3000") + .WithModuleName("untitled-circle-game").Build(); + + while (!connected) + { + conn.FrameTick(); + yield return null; + } + } + + UnityEngine.Vector2 ToVector2(Vector2 v) + { + return new UnityEngine.Vector2(v.X, v.Y); + } + + [UnityTest] + public IEnumerator CreatePlayerAndTestDecay() + { + var connected = false; + GameManager.OnConnect += () => + { + Debug.Log("Connected"); + connected = true; + }; + + PlayerPrefs.DeleteAll(); + SceneManager.LoadScene("Scenes/SampleScene"); + while(UIUsernameChooser.instance == null) + yield return null; + var playerCreated = false; + + GameManager.OnConnect += () => + { + Debug.Log("Connected"); + connected = true; + }; + + while (!connected) yield return null; + + GameManager.conn.Reducers.OnCreatePlayer += (_, _) => + { + Debug.Log("Player created"); + playerCreated = true; + }; + + UIUsernameChooser.instance.usernameInputField.text = "Test " + Random.Range(100000, 999999); + UIUsernameChooser.instance.PlayPressed(); + while (!playerCreated) yield return null; + + Debug.Assert(GameManager.localIdentity != null, "GameManager.localIdentity != null"); + var player = GameManager.conn.Db.Player.Identity.Find(GameManager.localIdentity); + Debug.Assert(player != null, nameof(player) + " != null"); + var circle = GameManager.conn.Db.Circle.Iter().FirstOrDefault(a => a.PlayerId == player.PlayerId); + + var foodEaten = 0; + GameManager.conn.Db.Food.OnDelete += (ctx, food) => + { + foodEaten++; + Debug.Log("Food eaten!"); + }; + + // Standing still should decay a bit + PlayerController.Local.EnableTestInput(); + while(foodEaten < 200) + { + Debug.Assert(circle != null, nameof(circle) + " != null"); + var ourEntity = GameManager.conn.Db.Entity.Id.Find(circle.EntityId); + var toChosenFood = new UnityEngine.Vector2(1000, 0); + uint chosenFoodId = 0; + foreach (var food in GameManager.conn.Db.Food.Iter()) + { + var thisFoodId = food.EntityId; + var foodEntity = GameManager.conn.Db.Entity.Id.Find(thisFoodId); + Debug.Assert(foodEntity != null, nameof(foodEntity) + " != null"); + Debug.Assert(ourEntity != null, nameof(ourEntity) + " != null"); + var foodEntityPosition = foodEntity.Position; + var ourEntityPosition = ourEntity.Position; + Debug.Assert(foodEntityPosition != null, nameof(foodEntityPosition) + " != null"); + Debug.Assert(ourEntityPosition != null, nameof(ourEntityPosition) + " != null"); + var toThisFood = ToVector2(foodEntity.Position) - ToVector2(ourEntity.Position); + if (toThisFood.sqrMagnitude == 0.0f) continue; + if (toChosenFood.sqrMagnitude > toThisFood.sqrMagnitude) + { + chosenFoodId = thisFoodId; + toChosenFood = toThisFood; + } + } + + if (GameManager.conn.Db.Entity.Id.Find(chosenFoodId) != null) + { + var ourNewEntity = GameManager.conn.Db.Entity.Id.Find(circle.EntityId); + var foodEntity = GameManager.conn.Db.Entity.Id.Find(chosenFoodId); + Debug.Assert(foodEntity != null, nameof(foodEntity) + " != null"); + Debug.Assert(ourNewEntity != null, nameof(ourNewEntity) + " != null"); + var toThisFood = ToVector2(foodEntity.Position) - ToVector2(ourNewEntity.Position); + PlayerController.Local.SetTestInput(toThisFood); + + } + + yield return null; + } + + + PlayerController.Local.SetTestInput(UnityEngine.Vector2.zero); + Debug.Assert(circle != null, nameof(circle) + " != null"); + var massStart = GameManager.conn.Db.Entity.Id.Find(circle.EntityId)!.Mass; + yield return new WaitForSeconds(10); + var massEnd = GameManager.conn.Db.Entity.Id.Find(circle.EntityId)!.Mass; + Debug.Assert(massEnd < massStart, "Mass should have decayed"); + } + + // [UnityTest] - This won't work until we have reconnections + public IEnumerator OneOffTest1() + { + var connected = false; + GameManager.OnConnect += () => + { + Debug.Log("Connected"); + connected = true; + }; + + PlayerPrefs.DeleteAll(); + SceneManager.LoadScene("Scenes/SampleScene"); + while (UIUsernameChooser.instance == null) + yield return null; + var playerCreated = false; + + GameManager.OnConnect += () => + { + Debug.Log("Connected"); + connected = true; + }; + + while (!connected) yield return null; + + GameManager.conn.Reducers.OnCreatePlayer += (ctx, username) => + { + Debug.Log("Player created"); + playerCreated = true; + }; + + var name = "Test " + Random.Range(100000, 999999); + UIUsernameChooser.instance.usernameInputField.text = name; + UIUsernameChooser.instance.PlayPressed(); + while (!playerCreated) yield return null; + + var task = GameManager.conn.OneOffQuery( + $"SELECT * FROM player WHERE identity=0x{GameManager.localIdentity}"); + Task.Run(() => task.RunSynchronously()); + while (!task.IsCompleted) yield return null; + var players = task.Result; + Debug.Assert(players.Length == 1, "Should have found one player"); + Debug.Assert(players[0].Name == name, "Username should match"); + Debug.Log($"id: {players[0].PlayerId} Username: {players[0].Name}"); + } + + // [UnityTest] - This won't work until we have reconnections + public IEnumerator OneOffTest2() + { + var connected = false; + GameManager.OnConnect += () => + { + Debug.Log("Connected"); + connected = true; + }; + + PlayerPrefs.DeleteAll(); + SceneManager.LoadScene("Scenes/SampleScene"); + while (UIUsernameChooser.instance == null) + yield return null; + var playerCreated = false; + + GameManager.OnConnect += () => + { + Debug.Log("Connected"); + connected = true; + }; + + while (!connected) yield return null; + + GameManager.conn.Reducers.OnCreatePlayer += (ctx, username) => + { + Debug.Log("Player created"); + playerCreated = true; + }; + + var name = "Test " + Random.Range(100000, 999999); + UIUsernameChooser.instance.usernameInputField.text = name; + UIUsernameChooser.instance.PlayPressed(); + while (!playerCreated) yield return null; + + var task = GameManager.conn.Db.Player.RemoteQuery($"WHERE identity=0x{GameManager.localIdentity}"); + Task.Run(() => task.RunSynchronously()); + while (!task.IsCompleted) yield return null; + var players = task.Result; + Debug.Assert(players.Length == 1, "Should have found one player"); + Debug.Assert(players[0].Name == name, "Username should match"); + Debug.Log($"id: {players[0].PlayerId} Username: {players[0].Name}"); + } + + //[UnityTest] + public IEnumerator ReconnectionViaReloadingScene() + { + var connected = false; + var subscribed = false; + GameManager.OnConnect += () => + { + connected = true; + + }; + GameManager.OnSubscriptionApplied += () => + { + subscribed = true; + }; + + Debug.Log("Initial scene load!"); + PlayerPrefs.DeleteAll(); + SceneManager.LoadScene("Scenes/SampleScene"); + while(UIUsernameChooser.instance == null) + yield return null; + var playerCreated = false; + + while (!connected) yield return null; + + GameManager.conn.Reducers.OnCreatePlayer += (_, _) => + { + Debug.Log("Player created"); + playerCreated = true; + }; + + var username = "Test " + Random.Range(100000, 999999); + UIUsernameChooser.instance.usernameInputField.text = username; + UIUsernameChooser.instance.PlayPressed(); + while (!playerCreated) yield return null; + + Debug.Assert(GameManager.localIdentity != null, "GameManager.localIdentity != null"); + var player = GameManager.conn.Db.Player.Identity.Find(GameManager.localIdentity); + Debug.Assert(player != null, nameof(player) + " != null"); + var circle = GameManager.conn.Db.Circle.Iter().FirstOrDefault(a => a.PlayerId == player.PlayerId); + + connected = false; + subscribed = false; + GameManager.instance.Disconnect(); + + Debug.Log("Second scene load!"); + // Reload + SceneManager.LoadScene("Scenes/SampleScene"); + + while(!connected || !subscribed) yield return null; + var newPlayer = GameManager.conn.Db.Player.Identity.Find(GameManager.localIdentity); + Debug.Assert(player.PlayerId == newPlayer.PlayerId, "PlayerIds should match!"); + var newCircle = GameManager.conn.Db.Circle.Iter().FirstOrDefault(a => a.PlayerId == newPlayer.PlayerId); + Debug.Assert(circle.EntityId == newCircle.EntityId, "Circle EntityIds should match!"); + } +} diff --git a/unity-tests~/client/Assets/PlayModeTests/PlayModeExampleTest.cs.meta b/unity-tests~/client/Assets/PlayModeTests/PlayModeExampleTest.cs.meta new file mode 100644 index 00000000..e76ef1f7 --- /dev/null +++ b/unity-tests~/client/Assets/PlayModeTests/PlayModeExampleTest.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 85f1a63472a5f294fbf03cf6ecf49c14 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity-tests~/client/Assets/PlayModeTests/PlayModeTests.asmdef b/unity-tests~/client/Assets/PlayModeTests/PlayModeTests.asmdef new file mode 100644 index 00000000..e9f8b069 --- /dev/null +++ b/unity-tests~/client/Assets/PlayModeTests/PlayModeTests.asmdef @@ -0,0 +1,25 @@ +{ + "name": "PlayModeTests", + "rootNamespace": "", + "references": [ + "UnityEngine.TestRunner", + "UnityEditor.TestRunner", + "SpacetimeDBCircleGame", + "com.clockworklabs.spacetimedbsdk", + "Unity.TextMeshPro" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll", + "SpacetimeDB.BSATN.Runtime.dll" + ], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [], + "noEngineReferences": false +} \ No newline at end of file diff --git a/unity-tests~/client/Assets/PlayModeTests/PlayModeTests.asmdef.meta b/unity-tests~/client/Assets/PlayModeTests/PlayModeTests.asmdef.meta new file mode 100644 index 00000000..08be2ce0 --- /dev/null +++ b/unity-tests~/client/Assets/PlayModeTests/PlayModeTests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: ba2066930f1a3da4c8b060605c0fc3a6 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: