diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml new file mode 100644 index 0000000..d2514e0 --- /dev/null +++ b/.github/workflows/pr-preview.yml @@ -0,0 +1,83 @@ +name: PR Preview Screenshot + +# Loads the default location in Unity play mode, renders a screenshot, and +# posts a direct download link as a comment on the pull request. +# +# Required repository secrets +# ──────────────────────────── +# UNITY_LICENSE – contents of a valid Unity .ulf license file +# UNITY_EMAIL – Unity account e-mail +# UNITY_PASSWORD – Unity account password + +on: + pull_request: + branches: [ main ] + +jobs: + screenshot: + name: Capture play-mode screenshot + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + pull-requests: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + lfs: true + + - name: Cache Unity Library + uses: actions/cache@v4 + with: + path: Library + key: Library-screenshot-${{ hashFiles('Assets/**', 'Packages/**', 'ProjectSettings/**') }} + restore-keys: | + Library-screenshot- + Library- + + - name: Run play-mode screenshot test + uses: game-ci/unity-test-runner@v4 + id: screenshot-test + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + with: + testMode: playMode + testFilter: VectorRoad.Tests.PlayMode.SceneScreenshotTests + artifactsPath: TestResults/playMode + githubToken: ${{ secrets.GITHUB_TOKEN }} + checkName: PR Preview Screenshot + + - name: Upload screenshot artifact + id: upload-screenshot + uses: actions/upload-artifact@v4 + if: always() + with: + name: pr-preview-screenshot + path: Screenshots/pr-preview.png + if-no-files-found: warn + retention-days: 14 + + - name: Post PR comment with download link + uses: actions/github-script@v7 + if: always() + env: + ARTIFACT_URL: ${{ steps.upload-screenshot.outputs.artifact-url }} + with: + script: | + const artifactUrl = process.env.ARTIFACT_URL; + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + const body = artifactUrl + ? `## 📸 PR Preview Screenshot\n\nA screenshot of the default location in play mode was captured for this PR.\n\n**[⬇️ Download Screenshot](${artifactUrl})**\n\n> Rendered at [workflow run](${runUrl})` + : `## 📸 PR Preview Screenshot\n\n⚠️ The screenshot could not be captured for this PR.\n\nSee the [workflow run](${runUrl}) for details.`; + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body, + }); diff --git a/Assets/Tests/PlayMode/SceneScreenshotTests.cs b/Assets/Tests/PlayMode/SceneScreenshotTests.cs new file mode 100644 index 0000000..a8db0cf --- /dev/null +++ b/Assets/Tests/PlayMode/SceneScreenshotTests.cs @@ -0,0 +1,113 @@ +using System.Collections; +using System.IO; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; +using VectorRoad.Core; + +namespace VectorRoad.Tests.PlayMode +{ + /// + /// Play-mode test that loads the default ProofOfConcept scene, waits + /// for the map-build pipeline to reach the + /// state, then renders a PNG screenshot to Screenshots/pr-preview.png + /// at the project root. + /// + /// + /// Designed to run in GitHub Actions via the pr-preview.yml workflow. + /// The screenshot artifact is uploaded and linked in a PR comment so + /// reviewers can see the rendered result at a glance. + /// + /// + /// + /// The startup menu is bypassed automatically by advancing the + /// state to + /// immediately after the scene loads. + /// + /// + public class SceneScreenshotTests + { + private const string SceneName = "ProofOfConcept"; + private const int ScreenshotWidth = 1920; + private const int ScreenshotHeight = 1080; + + /// + /// Loads the default location, waits for level generation to complete, + /// and saves a screenshot to Screenshots/pr-preview.png. + /// + [UnityTest] + [Timeout(300000)] // 5 minutes – map build can take a while in CI + public IEnumerator DefaultLocation_RendersScene() + { + yield return SceneManager.LoadSceneAsync(SceneName); + + // Allow Awake/Start to run on all objects in the loaded scene. + yield return null; + + // The MapSceneBuilder waits for the GameManager to leave MainMenu + // before it starts loading map data. Advance the state here to + // skip the interactive startup menu in automated runs. + var gm = GameManager.Instance; + if (gm != null && gm.CurrentState == GameState.MainMenu) + gm.SetState(GameState.LoadingMap); + + // Wait until the map build pipeline signals that the level is ready. + float elapsed = 0f; + const float mapLoadTimeout = 240f; // seconds + while (elapsed < mapLoadTimeout) + { + var instance = GameManager.Instance; + if (instance == null) + Assert.Fail("GameManager.Instance became null while waiting for map load."); + if (instance.CurrentState == GameState.Racing) + break; + elapsed += Time.deltaTime; + yield return null; + } + + // Give the physics engine and ChaseCam a few seconds to settle. + // The vehicle is spawned 2 m above the road surface and needs time to + // drop onto it; the ChaseCam uses SmoothDamp so it also needs several + // frames to move from its initial position to behind the vehicle. + yield return new WaitForSeconds(3f); + + // Find any active camera to render from. Camera.main returns the + // camera tagged "MainCamera", which is the expected render camera in + // the ProofOfConcept scene. FindFirstObjectByType is a safe fallback + // for scenes where the main camera tag has not been set. + var camera = Camera.main ?? Object.FindFirstObjectByType(); + Assert.IsNotNull(camera, "No Camera was found in the scene."); + + // Render the scene to a RenderTexture so the capture works reliably + // in headless / batch mode (no display required). + var rt = new RenderTexture(ScreenshotWidth, ScreenshotHeight, 24); + var prevTarget = camera.targetTexture; + camera.targetTexture = rt; + camera.Render(); + + var tex = new Texture2D(ScreenshotWidth, ScreenshotHeight, + TextureFormat.RGB24, false); + RenderTexture.active = rt; + tex.ReadPixels(new Rect(0, 0, ScreenshotWidth, ScreenshotHeight), 0, 0); + tex.Apply(); + + // Save to /Screenshots/pr-preview.png so the workflow + // can locate and upload the file as an artifact. + string screenshotDir = Path.GetFullPath( + Path.Combine(Application.dataPath, "..", "Screenshots")); + Directory.CreateDirectory(screenshotDir); + string screenshotPath = Path.Combine(screenshotDir, "pr-preview.png"); + File.WriteAllBytes(screenshotPath, tex.EncodeToPNG()); + + // Restore state and release GPU resources. + camera.targetTexture = prevTarget; + RenderTexture.active = null; + Object.Destroy(rt); + Object.Destroy(tex); + + Assert.IsTrue(File.Exists(screenshotPath), + $"Screenshot was not saved to {screenshotPath}"); + } + } +} diff --git a/Assets/Tests/PlayMode/SceneScreenshotTests.cs.meta b/Assets/Tests/PlayMode/SceneScreenshotTests.cs.meta new file mode 100644 index 0000000..6dad483 --- /dev/null +++ b/Assets/Tests/PlayMode/SceneScreenshotTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d053d836326f403683ba056925896fd7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: