diff --git a/packages/cultnet-ts/test/interop/cultnet-interop.test.ts b/packages/cultnet-ts/test/interop/cultnet-interop.test.ts index 48ac1b1c..ef7645ca 100644 --- a/packages/cultnet-ts/test/interop/cultnet-interop.test.ts +++ b/packages/cultnet-ts/test/interop/cultnet-interop.test.ts @@ -59,6 +59,8 @@ const csharpDllPath = resolve( ); const cultMeshTsRoot = resolve(cultLibRoot, "packages", "cultmesh-ts"); const cultMeshQuicPeerScript = resolve(cultMeshTsRoot, "dist-test", "test", "interop", "cultmesh-quic-peer.js"); +const quicNativeTestsProject = resolve(cultLibRoot, "tests", "GameCult.Mesh.Quic.Native.Tests", "GameCult.Mesh.Quic.Native.Tests.csproj"); +const quicManagedTestsProject = resolve(cultLibRoot, "tests", "GameCult.Mesh.Quic.Tests", "GameCult.Mesh.Quic.Tests.csproj"); const rustBinaryPath = resolve( cultnetRsRoot, "target", @@ -450,6 +452,180 @@ test("CultMesh QUIC realtime: C# managed provider and TypeScript consumer", asyn } }); +// --------------------------------------------------------------------------- +// Cut 5: the TypeScript QUIC realtime provider (the StreamPixels role). Four +// lanes, spec section 10. Lane 1 is the headline: it proves the Unity +// overlay's transport, and is gated to Windows because the native connector +// under test (`CultMeshNativeQuicRealtimeTransportConnector`) is. +// --------------------------------------------------------------------------- + +test("CultMesh QUIC realtime: TypeScript provider and C# native connector (the Unity path)", async (t) => { + if (process.platform !== "win32") { + t.skip("The native connector is Windows-only; this lane runs on Windows."); + return; + } + if (!process.env.CULTMESH_QUIC_NATIVE_DIR) { + t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge."); + return; + } + logInteropPhase("quic-provider-native", "build the typescript quic peer"); + await buildCultMeshTsQuicPeer(); + + const servers: RunningServeProcess[] = []; + t.after(async () => { + for (const server of servers) { + server.child.stdin?.end(); + server.child.kill(); + } + await delay(processCleanupTimeoutMs); + }); + + logInteropPhase("quic-provider-native", "start the typescript QUIC provider"); + servers.push(await spawnServeProcess("ts-quic-provider", { + command: process.execPath, + args: [cultMeshQuicPeerScript, "serve", "--frames", "5", "--delivery", "latest-only"], + cwd: cultMeshTsRoot, + env: { CULTMESH_QUIC_NATIVE_DIR: process.env.CULTMESH_QUIC_NATIVE_DIR ?? "" }, + stdin: "pipe", + })); + const ready = (await servers[servers.length - 1].ready) as { status: string; endpoint: string }; + assert.equal(ready.status, "ready"); + assert.match(ready.endpoint, /^cultmesh-state\+quic:\/\/127\.0\.0\.1:\d+\?cert-sha256=[0-9A-F]{64}$/); + + logInteropPhase("quic-provider-native", "dotnet test: the native connector dials the typescript provider"); + const { stdout } = await runDotnetTestFilter( + quicNativeTestsProject, + "NativeConnectorReceivesFromExternalProvider", + { CULTMESH_NATIVE_EXTERNAL_ENDPOINT: ready.endpoint }, + ); + assert.match(stdout, /Passed!/); + assert.doesNotMatch(stdout, /Skipped:\s*1/, `the env var should have made the test run, not skip.\n${stdout}`); +}); + +test("CultMesh QUIC realtime: TypeScript provider and C# managed connector, with golden bytes", async (t) => { + if (!process.env.CULTMESH_QUIC_NATIVE_DIR) { + t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge."); + return; + } + logInteropPhase("quic-provider-managed", "build the typescript quic peer"); + await buildCultMeshTsQuicPeer(); + + const servers: RunningServeProcess[] = []; + t.after(async () => { + for (const server of servers) { + server.child.stdin?.end(); + server.child.kill(); + } + await delay(processCleanupTimeoutMs); + }); + + logInteropPhase("quic-provider-managed", "start the typescript QUIC provider"); + servers.push(await spawnServeProcess("ts-quic-provider-managed", { + command: process.execPath, + args: [cultMeshQuicPeerScript, "serve", "--frames", "5", "--delivery", "latest-only"], + cwd: cultMeshTsRoot, + env: { CULTMESH_QUIC_NATIVE_DIR: process.env.CULTMESH_QUIC_NATIVE_DIR ?? "" }, + stdin: "pipe", + })); + const server = servers[servers.length - 1]; + const ready = (await server.ready) as { status: string; endpoint: string }; + assert.equal(ready.status, "ready"); + + logInteropPhase("quic-provider-managed", "dotnet test: the managed connector dials the typescript provider"); + const { stdout } = await runDotnetTestFilter( + quicManagedTestsProject, + "ManagedConnectorReceivesFromExternalProvider", + { CULTMESH_NATIVE_EXTERNAL_ENDPOINT: ready.endpoint }, + ); + assert.match(stdout, /Passed!/); + assert.doesNotMatch(stdout, /Skipped:\s*1/, `the env var should have made the test run, not skip.\n${stdout}`); + + const goldenMatch = /CULTMESH_GOLDEN_HEX=([0-9A-Fa-f]+)/.exec(stdout); + assert.ok(goldenMatch, `expected the C# test to print its golden hex line.\n${stdout}`); + const csharpHex = goldenMatch![1].toLowerCase(); + + // The typescript peer logs the hex of its own encoding of frame 1 (the + // first, and here the only, frame the managed connector's single + // ReceiveAsync call can have gotten) to stderr as it sends it. + const tsSentLine = server.stderr + .join("") + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + try { + return JSON.parse(line) as { event?: string; sent?: number; hex?: string }; + } catch { + return undefined; + } + }) + .find((entry) => entry?.event === "quic-realtime-serve" && entry.sent === 1); + assert.ok(tsSentLine?.hex, "expected the typescript peer to log frame 1's hex encoding"); + + assert.equal(csharpHex, tsSentLine!.hex, "TypeScript and C# must encode the same frame identically"); +}); + +test("CultMesh QUIC realtime: latest-only under a stalled consumer converges without blocking the provider", async (t) => { + if (!process.env.CULTMESH_QUIC_NATIVE_DIR) { + t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge."); + return; + } + logInteropPhase("quic-provider-stalled", "build the typescript quic peer"); + await buildCultMeshTsQuicPeer(); + + const servers: RunningServeProcess[] = []; + t.after(async () => { + for (const server of servers) { + server.child.stdin?.end(); + server.child.kill(); + } + await delay(processCleanupTimeoutMs); + }); + + logInteropPhase("quic-provider-stalled", "start the typescript QUIC provider"); + const started = Date.now(); + servers.push(await spawnServeProcess("ts-quic-provider-stalled", { + command: process.execPath, + args: [cultMeshQuicPeerScript, "serve", "--frames", "200", "--delivery", "latest-only", "--interval-ms", "1"], + cwd: cultMeshTsRoot, + env: { CULTMESH_QUIC_NATIVE_DIR: process.env.CULTMESH_QUIC_NATIVE_DIR ?? "" }, + stdin: "pipe", + })); + const ready = (await servers[servers.length - 1].ready) as { status: string; endpoint: string }; + + logInteropPhase("quic-provider-stalled", "dial twice: one healthy, one paused mid-stream"); + const env = { CULTMESH_QUIC_NATIVE_DIR: process.env.CULTMESH_QUIC_NATIVE_DIR ?? "" }; + const [healthy, paused] = await Promise.all([ + runJsonCommand("ts-quic-dial-healthy", process.execPath, [ + cultMeshQuicPeerScript, + "dial", + "--endpoint", ready.endpoint, + "--expect-sequence", "200", + "--timeout-ms", "20000", + ], cultMeshTsRoot, env), + runJsonCommand("ts-quic-dial-paused", process.execPath, [ + cultMeshQuicPeerScript, + "dial", + "--endpoint", ready.endpoint, + "--expect-sequence", "200", + "--pause-after", "5", + "--pause-ms", "500", + "--timeout-ms", "20000", + ], cultMeshTsRoot, env), + ]); + + // The provider's 200 broadcasts (bounded per-frame by --interval-ms 1, so + // ~200ms of pacing) plus the 500ms pause plus process/network overhead + // must still land well inside the bound below regardless of the pause: the + // provider itself was never blocked by the paused consumer. + assert.ok(Date.now() - started < 15_000, "the provider's broadcasts must not be blocked by a stalled peer"); + + assert.equal(healthy.frames.at(-1).sequence, "200"); + assert.equal(paused.frames.at(-1).sequence, "200"); + // Coalescing means the paused consumer saw far fewer than 200 frames. + assert.ok(paused.frames.length < 200, `expected coalescing under the pause, got ${paused.frames.length} frames`); +}); + test("CultNet TS/Rust/C#/Python peers discover each other and exchange raw state over the shared schema-v0 lane", async (t) => { logInteropPhase("schema-v0", "build peers"); await buildInteropPeers(); @@ -2501,6 +2677,40 @@ async function buildCultMeshTsQuicPeer(): Promise { await cultMeshTsInteropPeerBuild; } +/** + * Runs `dotnet test` against one project, filtered to a single test by name, + * with extra environment (normally `CULTMESH_NATIVE_EXTERNAL_ENDPOINT`). + * `verbosity=detailed` is what makes VSTest's console logger print a passed + * test's own `TestContext.Out` lines (the golden-bytes hex line, section + * 10's lane 4) instead of only failures. + */ +async function runDotnetTestFilter( + projectPath: string, + testName: string, + env: NodeJS.ProcessEnv, +): Promise<{ stdout: string; stderr: string }> { + try { + return await execFileAsync( + dotnetCommand, + [ + "test", + projectPath, + "--filter", + `FullyQualifiedName~${testName}`, + "--logger", + "console;verbosity=detailed", + "-nologo", + ], + { cwd: cultLibRoot, env: { ...process.env, ...env }, timeout: jsonCommandTimeoutMs }, + ); + } catch (error) { + const execError = error as { stdout?: string; stderr?: string; message: string }; + throw new Error( + `dotnet test ${testName} failed.\nstdout:\n${execError.stdout ?? ""}\nstderr:\n${execError.stderr ?? execError.message}`, + ); + } +} + async function buildKotlinInteropPeer(): Promise { kotlinInteropPeerBuild ??= execFileAsync( "powershell",