Add Cut 5's four QUIC realtime provider interop lanes
TypeScript provider vs C# native connector (the Unity path, gated to Windows since the native connector under test is); TypeScript provider vs C# managed connector, with a golden-bytes check comparing the C# test's re-encoded frame against the TypeScript peer's own encoding of the same frame; latest-only under a stalled consumer (two dials against one 200-frame provider, one paused mid-stream via the peer script's new --pause-after), asserting the provider's broadcasts stay bounded regardless of the pause and both consumers converge on sequence 200. Both dotnet-test lanes run `dotnet test --filter FullyQualifiedName~<test> --logger "console;verbosity=detailed"`: detailed verbosity is what surfaces a passed test's own TestContext.Out lines, which the golden-bytes lane reads back out of stdout. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
fa8a9f4e31
commit
d526286d60
1 changed files with 210 additions and 0 deletions
|
|
@ -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<void> {
|
|||
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<void> {
|
||||
kotlinInteropPeerBuild ??= execFileAsync(
|
||||
"powershell",
|
||||
|
|
|
|||
Loading…
Reference in a new issue