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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
130 changes: 102 additions & 28 deletions .github/workflows/live-obs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
#
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json

name: Live OBS validation
name: Full suite against live OBS

on:
workflow_dispatch:
Expand All @@ -26,7 +26,7 @@ env:

jobs:
validate:
name: Validate JSON and MessagePack against OBS
name: Unit and live tests against OBS
runs-on: ubuntu-24.04
timeout-minutes: 25

Expand Down Expand Up @@ -97,44 +97,118 @@ jobs:
pulseaudio --start --exit-idle-time=-1
pactl load-module module-null-sink sink_name=ci_sink

- name: Start OBS
- name: Build the tests
run: dotnet build ${{ github.workspace }}/ObsWebSocket.Tests/ObsWebSocket.Tests.csproj --configuration Release

- name: Run the full suite against OBS
# No filter: the unit tests and the live sweep in one run, so the coverage report covers
# everything at once. Each framework gets an OBS of its own: the write sweep resets video,
# and enumerating outputs after that reads a freed encoder and takes OBS down (#25), so a
# second framework sharing the first one's OBS would start against a poisoned instance.
env:
DISPLAY: ":99"
run: |
set -euo pipefail
obs --minimize-to-tray --disable-updater --disable-shutdown-check \
> obs-stdout.log 2>&1 &
echo $! > obs.pid

# Waited for rather than slept: pipeline init time varies with the runner.
for _ in $(seq 1 90); do
if nc -z localhost "${OBS_WEBSOCKET_PORT}"; then
echo "obs-websocket is listening."
exit 0
set -uo pipefail

start_obs() {
obs --minimize-to-tray --disable-updater --disable-shutdown-check \
> "obs-stdout-$1.log" 2>&1 &
echo $! > obs.pid
# Waited for rather than slept: pipeline init time varies with the runner.
for _ in $(seq 1 90); do
if nc -z localhost "${OBS_WEBSOCKET_PORT}"; then
return 0
fi
sleep 2
done
echo "::error::obs-websocket never started listening for $1."
cat "obs-stdout-$1.log"
return 1
}

stop_obs() {
# Terminated, not killed, so OBS clears its unclean-shutdown marker.
pid="$(cat obs.pid)"
kill "$pid" 2>/dev/null || true
for _ in $(seq 1 30); do
kill -0 "$pid" 2>/dev/null || return 0
sleep 1
done
kill -9 "$pid" 2>/dev/null || true
}

status=0
for framework in net9.0 net10.0 net11.0; do
echo "::group::$framework"
if start_obs "$framework"; then
dotnet test --project ${{ github.workspace }}/ObsWebSocket.Tests/ObsWebSocket.Tests.csproj \
--configuration Release --no-build --framework "$framework" \
--results-directory-layout per-module \
-- --test-parameter Obs.ServerUri=ws://localhost:${{ env.OBS_WEBSOCKET_PORT }} \
--test-parameter Obs.Password=${{ env.OBS_WEBSOCKET_PASSWORD }} \
--test-parameter Obs.Required=true \
--coverage --coverage-settings coverage.settings.xml \
--coverage-output-format cobertura --coverage-output full.cobertura.xml \
|| status=1
else
status=1
fi
sleep 2
stop_obs
echo "::endgroup::"
done
exit "$status"

echo "::error::obs-websocket never started listening on ${OBS_WEBSOCKET_PORT}."
cat obs-stdout.log
exit 1

- name: Validate both transports
# One process, both wire formats, one OBS. Exits non-zero on failure.
env:
DISPLAY: ":99"
Obs__ServerUri: ws://localhost:4455
Obs__Password: ${{ env.OBS_WEBSOCKET_PASSWORD }}
run: >
dotnet run --project ObsWebSocket.Example --configuration Release
-- run-transport-tests
- name: Enforce the coverage floor
# Every framework is gated, since each compiles its own conditional code paths.
if: always()
shell: bash
run: |
set -euo pipefail
python3 - <<'PY'
import glob, os, sys, xml.etree.ElementTree as ET

floors = {"Lines": 90.0, "Branches": 80.0}
reports = sorted(glob.glob("**/full.cobertura.xml", recursive=True))
summary = open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8")
if not reports:
summary.write("### Coverage\n\nNo coverage report was produced.\n")
sys.exit("No coverage report was produced.")

summary.write("### Coverage, unit and live combined\n\n")
summary.write("| Framework | Lines | Branches |\n| --- | ---: | ---: |\n")
failures = []
for report in reports:
framework = os.path.basename(os.path.dirname(report))
root = ET.parse(report).getroot()
rates = {
"Lines": float(root.get("line-rate") or 0) * 100,
"Branches": float(root.get("branch-rate") or 0) * 100,
}
summary.write(f"| {framework} | {rates['Lines']:.1f}% | {rates['Branches']:.1f}% |\n")
for metric, floor in floors.items():
if rates[metric] + 1e-9 < floor:
failures.append(f"{framework}: {metric.lower()} {rates[metric]:.1f}% is below {floor:.0f}%")

summary.write(f"\nFloors: {floors['Lines']:.0f}% lines, {floors['Branches']:.0f}% branches.\n")
if failures:
sys.exit("\n".join(failures))
print("coverage floors met for", len(reports), "framework(s)")
PY

- name: Upload coverage
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: coverage-full
if-no-files-found: warn
path: '**/full.cobertura.xml'

- name: Collect OBS logs
if: always()
run: |
set -euo pipefail
mkdir -p obs-logs
cp obs-stdout.log obs-logs/ 2>/dev/null || true
cp obs-stdout-*.log obs-logs/ 2>/dev/null || true
cp -r ~/.config/obs-studio/logs obs-logs/obs-studio-logs 2>/dev/null || true

- name: Upload OBS logs
Expand Down
19 changes: 10 additions & 9 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,16 @@ To contribute code, you'll need to set up a local development environment:
```bash
dotnet test --project ObsWebSocket.Tests -- --filter "TestCategory!=Integration"
```
5. **(Optional) Run Integration Tests:**
* These need a running OBS with obs-websocket enabled; point `Obs__ServerUri` and
`Obs__Password` at it.
* `dotnet test --project ObsWebSocket.Tests -- --filter "TestCategory=Integration"`
6. **(Optional) Validate both wire formats against that OBS:**
5. **(Optional) Run the Integration Tests:**
These need a running OBS with obs-websocket enabled. They call every request over both wire
formats and report inconclusive when no OBS is configured.
```bash
dotnet run --project ObsWebSocket.Example -- run-transport-tests
dotnet test --project ObsWebSocket.Tests --framework net11.0 -- --test-parameter Obs.ServerUri=ws://localhost:4455 --test-parameter Obs.Password=secret
```
Exits non-zero on the first failed check.
`Obs__ServerUri` and `Obs__Password` environment variables, or a `testsettings.local.json`
with an `Obs` section, work too. Run one framework per OBS start: the write sweep resets
video, and on Linux enumerating outputs afterwards takes OBS down (#25). CI starts a fresh OBS
for each framework.

## Checking the stub types

Expand Down Expand Up @@ -157,8 +158,8 @@ dotnet build ObsWebSocket.Core -t:RefreshObsProtocol -p:ObsProtocolCommit=<upstr
```

That fetches exactly that commit, re-pins the lock and regenerates. Commit the generated diff along
with `protocol.json` and `protocol.lock.json`, and run the live validation
(`ObsWebSocket.Example run-transport-tests`) before opening the pull request: a refresh is the change
with `protocol.json` and `protocol.lock.json`, and run the integration tests
against a live OBS before opening the pull request: a refresh is the change
most likely to alter a field's order, width or nullability, and that is not visible to the compiler.

## Tests
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,7 @@ ProtocolDefinition protocol

// --- Record Definition Start ---
mainBuilder.AppendLine("[MessagePackObject]");
AppendGeneratedAttributes(mainBuilder);
mainBuilder.AppendLine($"public sealed partial record {recordName}");
mainBuilder.AppendLine("{");
List<PropertyGenInfo> propertyInfos = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ ProtocolDefinition protocol
"/// Addresses one thing in OBS, so the requests about it need not restate which."
);
builder.AppendLine("/// </summary>");
AppendGeneratedAttributes(builder);
builder.AppendLine("public static class ObsWebSocketHandleExtensions");
builder.AppendLine("{");
foreach (string kind in byKind.Keys.OrderBy(k => k, StringComparer.Ordinal))
Expand Down
38 changes: 38 additions & 0 deletions ObsWebSocket.Codegen.Tasks/Generation/Emitter.Helpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,44 @@ FieldDefinition FieldDefinition
/// <summary>
/// Builds the standard C# file header for generated files.
/// </summary>
/// <summary>Name recorded in the generated code attribute.</summary>
private const string GeneratorName = "ObsWebSocket.Codegen";

/// <summary>Version recorded in the generated code attribute.</summary>
private static readonly string GeneratorVersion =
typeof(Emitter).Assembly.GetName().Version?.ToString() ?? "1.0.0.0";

/// <summary>
/// Marks a generated declaration, so analyzers and coverage collectors can tell it apart from
/// code someone maintains.
/// </summary>
/// <remarks>
/// Only for declarations that are generated in full. A partial whose other half is hand
/// written must not carry these: both attributes apply to the whole type.
/// </remarks>
/// <param name="builder">The builder to append to.</param>
/// <param name="indent">Indentation to prefix each attribute with.</param>
/// <param name="excludeFromCoverage">
/// <see langword="false"/> for a declaration the coverage attribute is not valid on, such as
/// an enum.
/// </param>
internal static void AppendGeneratedAttributes(
StringBuilder builder,
string indent = "",
bool excludeFromCoverage = true
)
{
builder.AppendLine(
$"{indent}[global::System.CodeDom.Compiler.GeneratedCode(\"{GeneratorName}\", \"{GeneratorVersion}\")]"
);
if (excludeFromCoverage)
{
builder.AppendLine(
$"{indent}[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]"
);
}
}

private static StringBuilder BuildSourceHeader(string? fileTypeComment = null)
{
StringBuilder builder = new();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ private static string BuildResolverRootSource()
builder.AppendLine("/// <summary>");
builder.AppendLine("/// MessagePack resolver for OBS WebSocket protocol DTOs.");
builder.AppendLine("/// </summary>");
AppendGeneratedAttributes(builder);
builder.AppendLine("public sealed class ObsWebSocketMsgPackResolver : IFormatterResolver");
builder.AppendLine("{");
builder.AppendLine(" /// <summary>");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ ProtocolDefinition protocol
"/// Handles for the things an event or a response already identifies by uuid."
);
builder.AppendLine("/// </summary>");
AppendGeneratedAttributes(builder);
builder.AppendLine("public static class ObsWebSocketPayloadHandles");
builder.AppendLine("{");

Expand Down
61 changes: 61 additions & 0 deletions ObsWebSocket.Codegen.Tasks/Generation/Emitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ public static void GenerateEnums(SourceProductionContext context, ProtocolDefini
builder.AppendLine("[System.Flags]");
}

AppendGeneratedAttributes(builder, excludeFromCoverage: false);
builder.AppendLine($"public enum {enumName} : {underlyingType}");
builder.AppendLine("{");
if (enumDef.EnumIdentifiers != null)
Expand Down Expand Up @@ -169,6 +170,7 @@ EnumDefinition enumDef
builder.AppendLine(
"/// <remarks>Generated from OBS WebSocket Protocol definition.</remarks>"
);
AppendGeneratedAttributes(builder);
builder.AppendLine($"public static class {className}");
builder.AppendLine("{");
if (enumDef.EnumIdentifiers != null)
Expand Down Expand Up @@ -364,6 +366,7 @@ private static string SnakeToPascalCase(string upperSnake) =>
builder.AppendLine(
"/// <remarks>Generated from OBS WebSocket Protocol definition.</remarks>"
);
AppendGeneratedAttributes(builder, excludeFromCoverage: false);
builder.AppendLine($"public enum {enumName}");
builder.AppendLine("{");
foreach ((string memberIdentifier, string wire) in members)
Expand All @@ -387,6 +390,7 @@ private static string SnakeToPascalCase(string upperSnake) =>
builder.AppendLine();

AppendXmlDocSummary(builder, $"Wire-value conversions for <see cref=\"{enumName}\"/>.", 0);
AppendGeneratedAttributes(builder);
builder.AppendLine($"public static class {enumName}Extensions");
builder.AppendLine("{");
builder.AppendLine(
Expand Down Expand Up @@ -776,13 +780,69 @@ ProtocolDefinition protocol
);
}
}
AppendEventDispatchTable(builder, protocol.Events);
builder.AppendLine("}");
context.AddSource(
"ObsWebSocketClient.Events.g.cs",
SourceText.From(builder.ToString(), Encoding.UTF8)
);
}

/// <summary>
/// Emits the table that routes each protocol event type to its typed handler, generated from
/// the same definition as the events themselves so no event can be declared yet never raised.
/// </summary>
private static void AppendEventDispatchTable(StringBuilder builder, List<OBSEvent> events)
{
bool anyDeprecated = events.Exists(e => e.Deprecated);

builder.AppendLine();
builder.AppendLine(
" /// <summary>Routes each protocol event type to the handler that raises its event.</summary>"
);
builder.AppendLine(
" private static global::System.Collections.Generic.Dictionary<string, global::System.Action<ObsWebSocketClient, global::ObsWebSocket.Core.Serialization.IWebSocketMessageSerializer, object?>> CreateEventDispatchTable()"
);
builder.AppendLine(" {");
if (anyDeprecated)
{
builder.AppendLine(
"#pragma warning disable CS0618 // Deprecated events still dispatch."
);
}

builder.AppendLine(
" return new global::System.Collections.Generic.Dictionary<string, global::System.Action<ObsWebSocketClient, global::ObsWebSocket.Core.Serialization.IWebSocketMessageSerializer, object?>>(global::System.StringComparer.Ordinal)"
);
builder.AppendLine(" {");
foreach (OBSEvent eventDef in events)
{
string eventName = SanitizeIdentifier(eventDef.EventType);
string argsType = $"{GeneratedEventArgsNamespace}.{eventName}EventArgs";
if (eventDef.DataFields?.Count > 0)
{
string payloadType = $"{GeneratedEventsNamespace}.{eventName}Payload";
builder.AppendLine(
$" [\"{eventDef.EventType}\"] = static (c, s, d) => c.TryHandleEvent<{payloadType}, {argsType}>(s, \"{eventDef.EventType}\", d, static p => new(p), c.On{eventName}),"
);
}
else
{
builder.AppendLine(
$" [\"{eventDef.EventType}\"] = static (c, _, _) => c.On{eventName}(new {argsType}()),"
);
}
}

builder.AppendLine(" };");
if (anyDeprecated)
{
builder.AppendLine("#pragma warning restore CS0618");
}

builder.AppendLine(" }");
}

#endregion

#region Client Extension Generation (Implementation in Emitter.Helpers.cs)
Expand Down Expand Up @@ -866,6 +926,7 @@ IGrouping<string, RequestDefinition> group in protocol
"/// Exposes the request categories defined by the OBS WebSocket protocol."
);
builder.AppendLine("/// </summary>");
AppendGeneratedAttributes(builder);
builder.AppendLine("public static class ObsWebSocketClientExtensions");
builder.AppendLine("{");
foreach ((string category, string groupName) in groups)
Expand Down
Loading
Loading