test — Conformance Suite

One YAML-driven harness proves all four SDKs behave identically against the same CLI.

The test/ directory contains language-agnostic conformance snapshots — YAML files describing a session scenario and its expected event stream. Every SDK ships a harness adapter that reads the YAML, drives the SDK, captures events, normalises them, and diffs against the snapshot. A single failing test indicates divergence between an SDK and the contract.

YAML snapshots 4 harnesses Deterministic diff

4
Harnesses
20+
Snapshot groups
YAML
Format
1
Contract

📚 What is this & why does it exist?

A harness + YAML snapshot infrastructure. Each test/snapshots/<feature>/*.yaml is a scenario: inputs, tool definitions, prompt, hook responses, expected final event stream. A per-SDK harness (Node: test/harness.ts; Python: test/harness.py; Go: go/internal/e2e; .NET: dotnet/test/Harness) turns the YAML into a live session, streams events, normalises timestamps/IDs, and asserts equality.

💡 Real-world analogy

Think of a chain of identical hotels. Each lobby must serve the same cappuccino — same temperature, same foam pattern, same saucer. Corporate HQ (YAML snapshots) defines the exact recipe. Each hotel has a local barista (SDK harness) following the recipe. An inspector (the test runner) orders from every hotel and measures. If one lobby's cappuccino is 2° off (one event extra or missing), the chain knows which branch drifted without re-auditing the recipe.

🎯 Why This Module Exists

Four SDKs + one protocol = a matrix of 4 implementations × N scenarios of "do we match the CLI?". Writing 4 copies of "send prompt, check response" tests would mean tests drift. A single set of YAML scenarios consumed by four harnesses enforces that the behaviour contract is one artefact — and any discrepancy surfaces as a normalised diff in CI, not as "oh, Go doesn't emit that event." This is also the mechanism used during protocol-version bumps: add the new scenario once; all four SDKs must implement and pass, or the PR is blocked.

🏗️ Design Decisions

DecisionChoice MadeAlternativesWhy
Snapshot formatYAMLJSON, TOML, custom DSLHuman-readable, supports multi-line prompts, widely tooled
Scenario scopePer-feature YAML file (permissions.yaml, hooks_extended.yaml)One giant file; one tiny file eachFeature-sized files match review granularity; easy to git-blame
Event-stream assertionNormalised equalitySubsequence match; schema-only checkFull equality catches ordering and count drift; normalisation strips non-deterministic fields (timestamps, UUIDs)
Harness locationCo-located with each SDKCentral Node-based harness driving all fourEach SDK's harness exercises the SDK's actual API, not a testing shim
CLI under testSame bundled CLI across harnessesLanguage-specific mocksOnly way to prove SDK-independent behaviour
Non-determinism handlingNormalise at the harness boundaryDeterministic replay; snapshot subsetsNormalisation is minimal & explicit; keeps snapshots readable

Harness Architecture

flowchart LR
  YAML[test/snapshots/<feature>/*.yaml
scenario + expected events] CLI[copilot CLI
bundled binary] subgraph harnesses["Per-SDK harnesses"] NH[Node: test/harness.ts] PH[Python: test/harness.py] GH[Go: go/internal/e2e] CH[.NET: dotnet/test/Harness] end subgraph sdks["SDKs"] NODE[nodejs/ Client/Session] PY[python/ Client/Session] GO[go/ Client/Session] DOT[dotnet/ Client/Session] end YAML --> NH YAML --> PH YAML --> GH YAML --> CH NH --> NODE PH --> PY GH --> GO CH --> DOT NODE --> CLI PY --> CLI GO --> CLI DOT --> CLI CLI -->|events| NODE CLI -->|events| PY CLI -->|events| GO CLI -->|events| DOT NODE -->|normalise + diff| REPORT[test report] PY -->|normalise + diff| REPORT GO -->|normalise + diff| REPORT CH -->|normalise + diff| REPORT

🚶 One Harness Run

A single Node harness invocation against permissions.yaml:

  1. Load scenario — parse YAML into a typed scenario object (prompt, tools[], hookResponses[], expectedEvents[]).
  2. Spawn SDK Client — new CopilotClient(); await client.start().
  3. Create session — register tools from scenario, wire onPermissionRequest to return the scenario's canned approvals/denials.
  4. Subscribe to events — push every received SessionEvent into an actual array.
  5. Send prompt — session.send({prompt: scenario.prompt}).
  6. Await idle — await session.onIdle().
  7. Normalise — strip volatile fields: timestamp, sessionId, messageId, trace-context IDs → placeholder strings.
  8. Diff — deepStrictEqual(actual, scenario.expectedEvents). Failures print structural diffs.
  9. Teardown — await session.disconnect(); await client.stop().
  10. Report — JUnit XML or JSON; CI annotates the PR with failures.

Key Components

PathRoleWhy
test/snapshots/permissions/Permission-gate scenariosCovers accept/deny/unattended paths
test/snapshots/hooks_extended/pre/post tool-use hook scenariosProve hook return values influence tool behaviour uniformly
test/snapshots/session_fs/Virtual FS scenariosEnsures provider interface contract is identical across SDKs
test/snapshots/commands/Slash-command scenariosDefines how /help, custom commands flow through events
test/snapshots/system_message/System-message replace/customize/transformThe three modes must yield specific event shapes
test/harness.tsNode harnessReference implementation.
test/harness.pyPython harnessUses asyncio to drive the SDK in reproducible loop.
go/internal/e2e/Go harnessStandard testing.T-integrated.
dotnet/test/Harness/.NET harnessxUnit-based.
test/normaliser.tsShared (conceptually) normalisation rulesEach SDK re-implements, but rules should match — this is tested by a meta-test.
test/README.mdAuthor's guideHow to add a scenario.

⚡ Normalisation Algorithm

Each harness applies a fixed set of replacements to the event stream before diffing:

The normaliser is careful to preserve structural relationships (a toolCallCompleted referencing TOOL_CALL_ID_2 must refer to the same call as the earlier toolCallStarted). Violations of this invariant are themselves a test failure.

Adding a Scenario

  1. Create test/snapshots/<feature>/my_case.yaml with scenario: + expectedEvents: sections.
  2. Run Node harness in record mode: npm run test:record -- my_case. Captures and writes expected events.
  3. Run all four harnesses in check mode: npm run test:conformance. All must pass.
  4. Submit PR; reviewer sees the YAML change + any generated code updates.

⚠ What Could Go Wrong

FailureCauseFix
One SDK diffs; others passSDK bug or missed eventDebug the one SDK; its harness prints the diff with event indices
All SDKs diff the same waySnapshot stale (protocol changed)Re-record; review the delta
Harness hangs on await idleSDK never emitted SessionIdleEventCLI/SDK bug; harness has a timeout wrapper that fails the test with context
Normaliser drops a needed fieldRule too broadNarrow the rule — assert on what you care about
Cross-SDK non-determinism (e.g., tool order)Underlying CLI reordersUse order-insensitive diff for that event group; documented per-scenario

📊 Performance

Each scenario spawns a CLI, runs one session, tears down. Typical wall clock: 0.5-3s per scenario per SDK. Running all four harnesses over 20 scenarios parallelised completes in under a minute. CI parallelises by SDK (not by scenario) because CLI reuse within a harness saves cold-start cost.

Deep-Dive Highlights

Protocol Negotiation Focus

How the harness verifies each SDK's version handshake against the same CLI.

Codegen Pipeline Focus

Why new scenarios usually need schema + codegen updates in the same PR.

Configuration Reference

KnobWherePurpose
COPILOT_CLI_PATHenvOverride CLI under test (useful for CLI dev)
RECORD=1envRegenerate expectedEvents instead of asserting
--filterCLI flagRun only matching scenarios
--timeoutCLI flagPer-scenario wall-clock cap
Normalisation rulesper-harnessMust match across harnesses; meta-tested
snapshot-dirharness argAlternate snapshot root (for fork testing)
JUnit output pathCLI flagCI annotation integration
Verbose diffCLI flagPrint full event stream on failure

Code Examples

A scenario YAML (illustrative)

scenario:
  name: deny_then_approve
  prompt: "Run ls in /tmp"
  tools:
    - name: shell
      parameters: { type: object, properties: { cmd: { type: string } } }
  permissionResponses:
    - match: { tool: shell }
      response: deny
    - match: { tool: shell }
      response: approve
expectedEvents:
  - type: assistantMessage
    content: { $match: "..." }
  - type: permissionRequested
    tool: shell
  - type: permissionDenied
  - type: permissionRequested
    tool: shell
  - type: permissionApproved
  - type: toolCallStarted
    id: <TOOL_CALL_ID_1>
  - type: toolCallCompleted
    id: <TOOL_CALL_ID_1>
  - type: sessionIdle

Normaliser snippet

function normalise(events: SessionEvent[]): SessionEvent[] {
    const idMap = new Map<string, string>();
    return events.map(e => {
        const copy = structuredClone(e);
        if ("timestamp" in copy) delete copy.timestamp;
        if ("sessionId" in copy) copy.sessionId = "<SESSION_ID>";
        if ("messageId" in copy) copy.messageId = mapId(idMap, "MSG", copy.messageId);
        if ("toolCallId" in copy) copy.toolCallId = mapId(idMap, "TOOL_CALL", copy.toolCallId);
        return copy;
    });
}

Node harness core

export async function runScenario(scenario: Scenario): Promise<Result> {
    const client = new CopilotClient();
    await client.start();
    const session = await client.createSession({
        tools: scenario.tools,
        onPermissionRequest: mkPermissionHandler(scenario.permissionResponses),
    });
    const actual: SessionEvent[] = [];
    session.on(e => actual.push(e));
    await session.send({ prompt: scenario.prompt });
    await session.onIdle();
    await client.stop();
    return diff(normalise(actual), scenario.expectedEvents);
}

Directory Map

test/ source tree
test/
├── snapshots/
│   ├── permissions/                   ← permission-flow scenarios
│   ├── hooks_extended/                ← pre/post-tool hooks
│   ├── session_fs/                    ← virtual FS provider
│   ├── commands/                      ← slash commands
│   ├── system_message/                ← replace/customize/transform
│   ├── basic/                         ← smoke-test scenarios
│   └── ...
├── harness.ts                         ← Node harness (reference)
├── harness.py                         ← Python harness
├── normaliser.ts                      ← Node normalisation helpers
├── README.md                          ← Author's guide
└── package.json

go/internal/e2e/                       ← Go harness (integrates with `go test`)
dotnet/test/Harness/                   ← .NET harness (xUnit)