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
100 changes: 100 additions & 0 deletions Explorer/Assets/DCL/FeatureFlags/AltTesterFeatureFlagsProbe.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#if ALTTESTER
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;

namespace DCL.FeatureFlags
{
/// <summary>
/// Feature flag state exposed to the AltTester suite via <c>AltDriver.CallStaticMethod</c>, so UI
/// tests read the values the client itself gates on instead of re-fetching the remote document and
/// re-deriving the evaluation rules. Compiles into the <c>DCL.Network</c> assembly.
/// Gated by the <c>ALTTESTER</c> compile define (stripped from release builds by <c>CloudBuild.cs</c>
/// when <c>IS_RELEASE_BUILD=true</c>), so the type is absent from shipping binaries.
/// </summary>
public static class AltTesterFeatureFlagsProbe
{
/// <summary>
/// Raw remote flag state, keyed without the <c>explorer-</c> prefix the server carries
/// (e.g. <c>alfa-marketplace-credits</c>).
/// </summary>
public static bool IsFlagEnabled(string flagId) =>
FeatureFlagsConfiguration.Instance.IsEnabled(flagId);

/// <summary>
/// Resolved <see cref="FeatureId"/> state, with the remote flag, app arguments and editor
/// overrides already folded together — this is what the UI gates on.
/// </summary>
/// <param name="featureId"><see cref="FeatureId"/> member name, case-insensitive.</param>
/// <exception cref="ArgumentException">The name is not a <see cref="FeatureId"/> member.</exception>
public static bool IsFeatureEnabled(string featureId) =>
FeaturesRegistry.Instance.IsEnabled(ParseFeatureId(featureId));

/// <summary>
/// The flag's variant and payload, for allowlist-style gating.
/// Shape: <c>{"present":true,"name":"wallets","enabled":true,"payloadType":"string","payloadValue":"0x1,0x2"}</c>.
/// </summary>
[SuppressMessage("ReSharper", "RedundantAnonymousTypePropertyName")]
public static string GetFlagVariantJson(string flagId)
{
if (!FeatureFlagsConfiguration.Instance.TryGetVariant(flagId, out FeatureFlagVariantDto variant))
return JsonConvert.SerializeObject(new { present = false });

return JsonConvert.SerializeObject(new
{
present = true,
name = variant.name,
enabled = variant.enabled,
payloadType = variant.payload.type,
payloadValue = variant.payload.value,
});
}

/// <summary>
/// Snapshot for failure diagnostics. Shape:
/// <c>{"flagsLoaded":true,"registryLoaded":true,"enabledFlags":["..."],"enabledFeatures":["..."]}</c>.
/// Never throws — a test calls this when something already went wrong.
/// </summary>
public static string GetStatusJson()
{
var enabledFlags = new List<string>();
var flagsLoaded = true;

try { enabledFlags.AddRange(FeatureFlagsConfiguration.Instance.AllEnabledFlags); }
catch (Exception) { flagsLoaded = false; }

var enabledFeatures = new List<string>();
var registryLoaded = true;

try
{
FeaturesRegistry registry = FeaturesRegistry.Instance;

foreach (FeatureId id in Enum.GetValues(typeof(FeatureId)))
{
if (id != FeatureId.None && registry.IsEnabled(id))
enabledFeatures.Add(id.ToString());
}
}
catch (Exception) { registryLoaded = false; }

return JsonConvert.SerializeObject(new
{
flagsLoaded,
registryLoaded,
enabledFlags,
enabledFeatures,
});
}

private static FeatureId ParseFeatureId(string featureId)
{
if (!Enum.TryParse(featureId, true, out FeatureId parsed) || !Enum.IsDefined(typeof(FeatureId), parsed))
throw new ArgumentException($"'{featureId}' is not a {nameof(FeatureId)} member", nameof(featureId));

return parsed;
}
}
}
#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,181 @@
#if ALTTESTER
using Global.AppArgs;
using Newtonsoft.Json;
using NSubstitute;
using NUnit.Framework;
using System;
using System.Diagnostics.CodeAnalysis;

namespace DCL.FeatureFlags.Tests
{
public class AltTesterFeatureFlagsProbeShould
{
private const string FLAGS_JSON = @"{""flags"":{""alfa-marketplace-credits"":true,""alfa-friends"":true,""disabled-ff"":false},""variants"":{""alfa-marketplace-credits"":{""name"":""wallets"",""payload"":{""type"":""string"",""value"":""0x1,0x2""},""enabled"":true}}}";

[SetUp]
public void SetUp()
{
// Other suites in this assembly initialize the singletons without resetting them.
FeaturesRegistry.Reset();
FeatureFlagsConfiguration.Reset();
}

[TearDown]
public void TearDown()
{
FeaturesRegistry.Reset();
FeatureFlagsConfiguration.Reset();
}

[TestCase("alfa-marketplace-credits", true)]
[TestCase("alfa-friends", true)]
[TestCase("disabled-ff", false)]
[TestCase("absent-ff", false)]
public void ReadRawFlagState(string flagId, bool expected)
{
// Arrange
InitializeFlags(FLAGS_JSON);

// Act
bool enabled = AltTesterFeatureFlagsProbe.IsFlagEnabled(flagId);

// Assert
Assert.AreEqual(expected, enabled);
}

[TestCase("MarketplaceCredits")]
[TestCase("marketplacecredits")]
public void ResolveFeatureIdCaseInsensitively(string featureId)
{
// Arrange
InitializeFlags(FLAGS_JSON);
InitializeRegistry();

// Act
bool enabled = AltTesterFeatureFlagsProbe.IsFeatureEnabled(featureId);

// Assert
Assert.IsTrue(enabled);
}

[Test]
public void ReadFeatureStateFromRegistryNotFromFlag()
{
// Arrange — the flag drives MarketplaceCredits, so an empty document turns it off.
InitializeFlags(@"{""flags"":{},""variants"":{}}");
InitializeRegistry();

// Act
bool enabled = AltTesterFeatureFlagsProbe.IsFeatureEnabled("MarketplaceCredits");

// Assert
Assert.IsFalse(enabled);
}

[TestCase("NoSuchFeature")]
[TestCase("9999")]
[TestCase("")]
public void ThrowOnUnknownFeatureId(string featureId)
{
// Arrange
InitializeFlags(FLAGS_JSON);
InitializeRegistry();

// Act / Assert — a typo must fail loudly instead of reading as "off".
Assert.Throws<ArgumentException>(() => AltTesterFeatureFlagsProbe.IsFeatureEnabled(featureId));
}

[Test]
public void ReadVariantPayload()
{
// Arrange
InitializeFlags(FLAGS_JSON);

// Act
var variant = JsonConvert.DeserializeObject<VariantDto>(
AltTesterFeatureFlagsProbe.GetFlagVariantJson("alfa-marketplace-credits"));

// Assert
Assert.IsTrue(variant.present);
Assert.AreEqual("wallets", variant.name);
Assert.IsTrue(variant.enabled);
Assert.AreEqual("string", variant.payloadType);
Assert.AreEqual("0x1,0x2", variant.payloadValue);
}

[Test]
public void ReportAbsentVariant()
{
// Arrange
InitializeFlags(FLAGS_JSON);

// Act
var variant = JsonConvert.DeserializeObject<VariantDto>(
AltTesterFeatureFlagsProbe.GetFlagVariantJson("alfa-friends"));

// Assert
Assert.IsFalse(variant.present);
}

[Test]
public void ListEnabledFlagsAndFeaturesInStatus()
{
// Arrange
InitializeFlags(FLAGS_JSON);
InitializeRegistry();

// Act
StatusDto status = JsonConvert.DeserializeObject<StatusDto>(AltTesterFeatureFlagsProbe.GetStatusJson());

// Assert
Assert.IsTrue(status.flagsLoaded);
Assert.IsTrue(status.registryLoaded);
Assert.Contains("alfa-marketplace-credits", status.enabledFlags);
Assert.Contains("alfa-friends", status.enabledFlags);
Assert.IsFalse(Array.Exists(status.enabledFlags, flag => flag == "disabled-ff"));
Assert.Contains("MarketplaceCredits", status.enabledFeatures);
}

[Test]
public void ReportNotLoadedInStatusInsteadOfThrowing()
{
// Arrange — nothing initialized, as when a test probes before login completes.

// Act
StatusDto status = JsonConvert.DeserializeObject<StatusDto>(AltTesterFeatureFlagsProbe.GetStatusJson());

// Assert
Assert.IsFalse(status.flagsLoaded);
Assert.IsFalse(status.registryLoaded);
}

private static void InitializeFlags(string json) =>
FeatureFlagsConfiguration.Initialize(
new FeatureFlagsConfiguration(JsonConvert.DeserializeObject<FeatureFlagsResultDto>(json)));

private static void InitializeRegistry() =>
FeaturesRegistry.Initialize(new FeaturesRegistry(Substitute.For<IAppArgs>(), false));

[Serializable]
[SuppressMessage("ReSharper", "InconsistentNaming")]
private struct VariantDto
{
public bool present;
public string name;
public bool enabled;
public string payloadType;
public string payloadValue;
}

[Serializable]
[SuppressMessage("ReSharper", "InconsistentNaming")]
private struct StatusDto
{
public bool flagsLoaded;
public bool registryLoaded;
public string[] enabledFlags;
public string[] enabledFeatures;
}
}
}
#endif

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

2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ Welcome to the official documentation for Unity Explorer — the Decentraland cl
## Testing & Debugging
- **[Debug Container & Widgets](debug-container-and-widgets.md)** — Runtime debug panel architecture, widget builder API, bindings, and integration patterns
- **[Testing Guide](testing-guide.md)** — UnitySystemTestBase, ECS test utilities, mocking, EditMode/PlayMode, async test patterns
- **[Automation Testing](automation-testing.md)** — AltTester SDK setup, writing UI automation tests, running against instrumented builds and in-Editor, triggering visual regression on PRs via `/visual-tests`
- **[Automation Testing](automation-testing.md)** — AltTester SDK setup, writing UI automation tests, static probes for reading client state, running against instrumented builds and in-Editor, triggering visual regression on PRs via `/visual-tests`
- **[MCP Automation](mcp-automation.md)** — Embedded MCP server for coding agents: screenshots, player/scene state, scene logs, and player control via `--mcp`
- **[Connect to Local Scene](how-to-connect-to-a-local-scene.md)** — Running and connecting to local SDK7 scenes
- **[Master of Bots](master-of-bots.md)** — Simulating multiple bot users for load testing
Expand Down
31 changes: 31 additions & 0 deletions docs/automation-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,37 @@ The project follows the **Page Object Model (POM)** pattern. See the [explorer-a

---

## Static Probes

Some state is awkward to read through the UI hierarchy, and some — resolved feature flags, scene
loading progress — isn't in it at all. The client exposes that state as plain static methods the
tests call directly with `AltDriver.CallStaticMethod`, passing the type and its assembly:

```csharp
AltDriver.CallStaticMethod<bool>(
"DCL.FeatureFlags.AlttesterFeatureFlagsProbe", "IsFeatureEnabled",
"DCL.Network", new object[] { "MarketplaceCredits" });
```

| Type | Assembly | Exposes |
|---|---|---|
| `SceneRunner.Scene.AlttesterSceneReadinessProbe` | `SceneRunner.Scene` | whether the current scene finished loading, plus its name and base parcel |
| `DCL.FeatureFlags.AlttesterFeatureFlagsProbe` | `DCL.Network` | remote flag state, resolved `FeatureId` state, variant payloads |
| `DCL.PerformanceAndDiagnostics.AutoPilot.PerfSampler` | `DCL.Diagnostics.AutoPilot` | `Begin`/`End` around a test to write a perf CSV |

The `Alttester*` probes are gated by the `ALTTESTER` define and are therefore absent from release
builds; `PerfSampler` is not gated.

**Prefer a probe over re-deriving client state test-side.** Feature flags are the cautionary case:
the remote document evaluates differently depending on request headers, and the client folds app
arguments and editor overrides on top, so a suite that fetches the flags itself can get a different
answer than the UI it is asserting against.

When adding one: keep it static, return a primitive or a JSON string, gate it with `#if ALTTESTER`,
and put it in an assembly that already sees the state being exposed.

---

## CI Pipeline

Non-release builds created by CI (for PRs and the dev branch) include AltTester instrumentation. The instrumented build:
Expand Down
4 changes: 4 additions & 0 deletions docs/feature-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ public void DoStuff()
}
```

From an automation test, read the flags out of the running client via
`AlttesterFeatureFlagsProbe` rather than fetching the remote document — see
[Static Probes](automation-testing.md#static-probes).

## How to get content of a feature flag (variants)

Refer to: https://gh.getunleash.io/reference/strategy-variants#what-are-strategy-variants
Expand Down
Loading