Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
adc209c
ci(audience): run Linux PlayMode under xvfb so tests actually execute…
ImmutableJeffrey May 8, 2026
354b52d
ci(audience): swap xmllint guard for grep parse (SDK-317)
ImmutableJeffrey May 8, 2026
5806341
ci(audience): capture standalone test player log on Linux (SDK-317)
ImmutableJeffrey May 8, 2026
2380f5d
feat(audience-sample): mirror SDK output to Debug.Log (SDK-317)
ImmutableJeffrey May 8, 2026
ca2b367
ci(audience): cap Unity invocation at 22 min so shutdown hang cannot …
ImmutableJeffrey May 8, 2026
9106a74
ci(audience): stamp CI run + cell + buildGuid into Player.log and job…
ImmutableJeffrey May 8, 2026
54cbf4a
ci(audience): replace fixed timeout with log-driven watchdog (SDK-317)
ImmutableJeffrey May 8, 2026
5c2a9c5
fix(audience): treat missing queue dir as empty queue in ReadBatch (S…
ImmutableJeffrey May 8, 2026
356d18d
test(audience): add ci marker test that stamps run metadata on CDP (S…
ImmutableJeffrey May 8, 2026
3420c12
ci(audience): lift ci marker env vars to workflow / job scope (SDK-317)
ImmutableJeffrey May 9, 2026
923cc69
chore(audience): log every DiskStore counter mutation (SDK-341 trace)
ImmutableJeffrey May 9, 2026
8bdb0b0
ci(audience): shrink xvfb screen and force GLcore for Unity 6 perf (S…
ImmutableJeffrey May 9, 2026
aaf44cb
test(audience-sample): poll at 50ms wall-clock instead of per-frame
ImmutableJeffrey May 9, 2026
799ae23
ci(audience): force StandaloneLinux64 player to OpenGLCore only at bu…
ImmutableJeffrey May 9, 2026
e7718f6
ci(audience): drop redundant xvfb -screen flag from server-args
ImmutableJeffrey May 9, 2026
2d03ea7
ci(audience): fix apostrophe in docker bash heredoc comment
ImmutableJeffrey May 9, 2026
5c1040c
test(audience-sample): capture player-side profile on Linux PlayMode …
ImmutableJeffrey May 9, 2026
f2f7037
test(audience-sample): hide log pane on Linux PlayMode to skip llvmpi…
ImmutableJeffrey May 9, 2026
1834c9b
chore(audience-sample): trim unused packages and engine modules from …
ImmutableJeffrey May 9, 2026
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
299 changes: 289 additions & 10 deletions .github/workflows/test-audience-sample-app.yml

Large diffs are not rendered by default.

56 changes: 56 additions & 0 deletions examples/audience/Assets/Editor/GraphicsApisLinuxOverride.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#nullable enable

using System;
using UnityEditor;
using UnityEditor.Build;
using UnityEditor.Build.Reporting;
using UnityEngine;
using UnityEngine.Rendering;

namespace Immutable.Audience.Samples.SampleApp.Editor
{
// Build pre-process hook that restricts the StandaloneLinux64 player to
// OpenGLCore when AUDIENCE_LINUX_GLCORE_ONLY is set.
//
// Why: the unityci/editor Linux container has no GPU. Unity falls back
// to Mesa software OpenGL via llvmpipe. The runtime -force-glcore flag
// picks OpenGL when the player launches, but the build still ships
// every active graphics API's shader variants. With Vulkan also active
// by default, the shader compiler emits both glcore and vulkan
// variants. Roughly half of the 413 shader compiles measured on Unity
// 6 Linux were wasted on Vulkan variants the player never used.
//
// The hook runs only when the env flag is set, only for the
// StandaloneLinux64 build target, and only modifies in-memory
// PlayerSettings during the build. Other Standalone targets (Win, Mac)
// and other CI workflows are unaffected. Local builds without the env
// var see no change.
//
// Usage:
// AUDIENCE_LINUX_GLCORE_ONLY=1 Unity -batchmode -buildTarget StandaloneLinux64 -runTests ...
internal sealed class GraphicsApisLinuxOverride : IPreprocessBuildWithReport
{
private const string EnvVar = "AUDIENCE_LINUX_GLCORE_ONLY";

public int callbackOrder => 1;

public void OnPreprocessBuild(BuildReport report)
{
if (report.summary.platform != BuildTarget.StandaloneLinux64) return;

var requested = Environment.GetEnvironmentVariable(EnvVar);
if (string.IsNullOrEmpty(requested)) return;

var current = PlayerSettings.GetGraphicsAPIs(BuildTarget.StandaloneLinux64);
if (current.Length == 1 && current[0] == GraphicsDeviceType.OpenGLCore)
{
Debug.Log($"[{nameof(GraphicsApisLinuxOverride)}] StandaloneLinux64 already at OpenGLCore only.");
return;
}

PlayerSettings.SetUseDefaultGraphicsAPIs(BuildTarget.StandaloneLinux64, false);
PlayerSettings.SetGraphicsAPIs(BuildTarget.StandaloneLinux64, new[] { GraphicsDeviceType.OpenGLCore });
Debug.Log($"[{nameof(GraphicsApisLinuxOverride)}] StandaloneLinux64 graphics APIs forced to OpenGLCore. Vulkan shader variants will be skipped.");
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 38 additions & 4 deletions examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,23 @@ public sealed partial class AudienceSample : MonoBehaviour

// ---- Lifecycle ----

// Stamps a single, unique CI provenance line into Player.log on
// player startup. buildGuid is per-build and already lands on every
// game_launch event the SDK emits, so matching a CI artifact to the
// CDP rows is a one-line grep on either side. AUDIENCE_TEST_RUN_ID
// and AUDIENCE_TEST_CELL_ID are env vars the CI workflow injects;
// local / production runs leave them unset and the printed values
// are empty. The line lands before any test runs because the
// RuntimeInitialize hook fires before the first scene loads.
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void LogCiProvenance()
{
var runId = Environment.GetEnvironmentVariable("AUDIENCE_TEST_RUN_ID") ?? string.Empty;
var cellId = Environment.GetEnvironmentVariable("AUDIENCE_TEST_CELL_ID") ?? string.Empty;
UnityEngine.Debug.Log(
$"[CI] buildGuid={Application.buildGUID} runId={runId} cellId={cellId}");
}

private void Awake()
{
// InitializeUi must precede the Log.Writer swap — _logView has
Expand Down Expand Up @@ -246,15 +263,26 @@ private void OnAlias() => RunAndLog("alias()", () =>

// Fires from background flush threads; AppendLog marshals to main.
// Body is JSON for parity with handler "Copy" output.
private void OnSdkError(AudienceError err) =>
AppendLog("onError", Json.Serialize(new Dictionary<string, object>
// Also forwards to Debug.LogError so the entry lands in Player.log
// / Editor console. Without this, OnError fires would be visible
// only in the in-app log pane, which disappears with the player
// process and is not captured by NUnit's test-results.xml. That
// gap let HTTP / cert / 4xx-5xx failures pass under "39 passed"
// for the StandaloneLinux64 cells.
private void OnSdkError(AudienceError err)
{
var body = Json.Serialize(new Dictionary<string, object>
{
["code"] = err.Code.ToString(),
["message"] = err.Message,
}, 2), LogLevel.Err, LogSource.Sdk);
}, 2);
UnityEngine.Debug.LogError($"[Audience.OnError] {body}");
AppendLog("onError", body, LogLevel.Err, LogSource.Sdk);
}

// SDK Log.Writer adapter. May fire from any thread; AppendLog handles
// the main-thread marshal.
// the main-thread marshal. Also mirrors to Unity's Debug.Log so the
// SDK's warnings reach Player.log alongside the in-app pane.
private void RouteSdkLogToPane(string msg)
{
const string warnTag = "[ImmutableAudience] WARN:";
Expand All @@ -265,10 +293,16 @@ private void RouteSdkLogToPane(string msg)
{
level = LogLevel.Warn;
body = msg.Substring(warnTag.Length).TrimStart();
UnityEngine.Debug.LogWarning($"[Audience] {body}");
}
else if (msg.StartsWith(prefix, StringComparison.Ordinal))
{
body = msg.Substring(prefix.Length).TrimStart();
UnityEngine.Debug.Log($"[Audience] {body}");
}
else
{
UnityEngine.Debug.Log($"[Audience] {body}");
}
AppendLog("sdk", body, level, LogSource.Sdk);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#nullable enable

#if UNITY_STANDALONE_LINUX
using NUnit.Framework;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UIElements;

namespace Immutable.Audience.Samples.SampleApp.Tests
{
// Linux PlayMode test optimisation: hide the SampleApp log pane so UI
// Toolkit does not generate triangles for its rows during test runs.
//
// The player profile captured on PR 765 showed Render Thread spending
// roughly 4.5 seconds per frame in Gfx.PresentFrame self time on Unity
// 6 Linux cells, with ~2920 batches and ~7520 triangles per frame.
// Camera.Render is 2 ms, UI.RenderOverlays 1.45 ms; the rest is
// llvmpipe rasterising the deferred command buffer at present time.
// The bulk of those triangles come from the log pane, which
// accumulates one row per logged event over the course of a session.
//
// display:none keeps elements in the visual tree (so VisualElement.Q
// and contentContainer.Children() still find them) but skips layout
// and render entirely. Tests assert on log entries via userData on
// each row, which is reference-based, not layout-based, so the
// assertions stay correct.
//
// Engages only on StandaloneLinux64 builds (gated by the #if). Mac
// and Windows PlayMode runs are unaffected.
[SetUpFixture]
public sealed class LinuxLogPaneSuppression
{
[OneTimeSetUp]
public void RegisterSceneHook()
{
SceneManager.sceneLoaded += HideLogPane;
}

[OneTimeTearDown]
public void DeregisterSceneHook()
{
SceneManager.sceneLoaded -= HideLogPane;
}

// Re-fires for every scene load. The SampleApp's UIDocument runs
// its UI initialisation in Awake, so by the time sceneLoaded
// fires the log ScrollView is in the tree and ready to be
// styled. The hook is idempotent across loads.
private static void HideLogPane(Scene scene, LoadSceneMode mode)
{
var sample = Object.FindFirstObjectByType<AudienceSample>(FindObjectsInactive.Include);
if (sample == null) return;

var doc = sample.GetComponent<UIDocument>();
if (doc == null) return;

var root = doc.rootVisualElement;
if (root == null) return;

var log = root.Q<ScrollView>(SampleAppUi.LogScrollView);
if (log == null) return;

log.style.display = new StyleEnum<DisplayStyle>(DisplayStyle.None);
Debug.Log("[LinuxLogPaneSuppression] log pane hidden for Linux PlayMode test run.");
}
}
}
#endif

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#nullable enable

#if UNITY_STANDALONE_LINUX
using System;
using System.IO;
using UnityEngine;
using UnityEngine.Profiling;

namespace Immutable.Audience.Samples.SampleApp.Tests
{
// Linux PlayMode test player profiler hook.
//
// The editor profile we captured on PR #764 only covered the editor
// process. The actual test loop runs in a separate PlayerWithTests
// subprocess, which never had a profiler attached. This hook plugs
// that gap from inside the player itself.
//
// Behaviour: at BeforeSceneLoad, reads AUDIENCE_PLAYER_PROFILE_PATH
// from the player process env. When set, points
// UnityEngine.Profiling.Profiler at that path and starts a binary
// log of every captured frame. Output can be loaded into Unity
// Editor: Window > Analysis > Profiler > Load Profile.
//
// Engages only on StandaloneLinux64 builds (gated by the #if) and
// only when the env var is set (gated at runtime). Local dev builds
// and other-platform CI runs are unaffected.
//
// Note: this enables regular profiling, not deep profiling. Deep
// profiling is set at editor build time via the -deepprofiling
// command line flag and cannot be toggled from runtime code. Regular
// profiling still surfaces per-frame CPU breakdowns and the function
// hot list, which is what we need to identify the per-frame UI
// Toolkit cost (or whatever else is eating ~37 sec per test).
public static class PlayerProfilerLogger
{
private const string PathEnvVar = "AUDIENCE_PLAYER_PROFILE_PATH";

[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
public static void Init()
{
var path = Environment.GetEnvironmentVariable(PathEnvVar);
if (string.IsNullOrEmpty(path)) return;

try
{
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}

Profiler.logFile = path;
Profiler.enableBinaryLog = true;
Profiler.enabled = true;

Debug.Log($"[PlayerProfilerLogger] Profiler binary log enabled. Output: {path}");
}
catch (Exception ex)
{
Debug.LogWarning($"[PlayerProfilerLogger] Failed to enable profiler at {path}: {ex.Message}");
}
}
}
}
#endif

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,30 @@ private IEnumerator SetConsentVia(string consentButtonName)

// ---- Tests ----

// Marks this as coming from CI.
[UnityTest]
public IEnumerator AudienceCiTestMarker_EmitsRunMetadata()
{
var runId = Environment.GetEnvironmentVariable("AUDIENCE_TEST_RUN_ID");
var cellId = Environment.GetEnvironmentVariable("AUDIENCE_TEST_CELL_ID");
if (string.IsNullOrEmpty(runId) && string.IsNullOrEmpty(cellId))
{
Assert.Ignore("Not running in CI.");
yield break;
}

yield return LoadAndInit();

ImmutableAudience.Track("audience_ci_test_marker", new System.Collections.Generic.Dictionary<string, object>
{
["source"] = "ci",
["ciRunId"] = runId ?? string.Empty,
["ciCellId"] = cellId ?? string.Empty,
});

yield return null;
}

[UnityTest]
public IEnumerator InitTrackFlush_AgainstSandbox_FlushReportsOk()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,35 +15,46 @@ namespace Immutable.Audience.Samples.SampleApp.Tests
// the log pane via the userData stash on each log row.
internal static class SampleAppTestHelpers
{
// Polls predicate once per frame until it returns true or the deadline
// elapses. Calls Assert.Fail with description when the deadline is hit.
// Use this instead of WaitForSecondsRealtime when a test is waiting
// "at most N seconds for X to become true" — the polling exits as soon
// as the condition is satisfied rather than burning the full N seconds.
// Wall-clock interval between predicate checks for WaitForCondition
// and WaitForLogEntry. Decoupled from frame pacing so tests poll at a
// fixed cadence regardless of how fast the player renders. On Linux
// PlayMode under llvmpipe Unity 6 runs at 1 to 2 fps; per-frame
// polling there made the polling interval one full second, so a
// suite that should finish in seconds dragged into tens of minutes.
private const float PollIntervalSeconds = 0.05f;

// Polls predicate at PollIntervalSeconds wall-clock cadence until it
// returns true or the deadline elapses. Calls Assert.Fail with
// description when the deadline is hit. Use this instead of
// WaitForSecondsRealtime when a test is waiting "at most N seconds
// for X to become true": the polling exits as soon as the condition
// is satisfied rather than burning the full N seconds.
internal static IEnumerator WaitForCondition(
Func<bool> predicate, float timeoutSeconds, string description)
{
var deadline = Time.realtimeSinceStartup + timeoutSeconds;
var poll = new WaitForSecondsRealtime(PollIntervalSeconds);
while (Time.realtimeSinceStartup < deadline)
{
if (predicate()) yield break;
yield return null;
yield return poll;
}
Assert.Fail($"Timed out after {timeoutSeconds:F1}s waiting for: {description}");
}

// Wait until the log pane contains an entry whose label matches `label`
// and whose level matches `level`. Yields one frame per check.
// and whose level matches `level`. Polls at PollIntervalSeconds.
// Throws TimeoutException on deadline.
internal static IEnumerator WaitForLogEntry(
VisualElement root, string label, int level, float timeoutSec)
{
var deadline = Time.realtimeSinceStartup + timeoutSec;
var poll = new WaitForSecondsRealtime(PollIntervalSeconds);
while (Time.realtimeSinceStartup < deadline)
{
if (HasLogEntry(root, label, level))
yield break;
yield return null;
yield return poll;
}
throw new TimeoutException(
$"Log entry not found within {timeoutSec}s. " +
Expand Down
Loading
Loading