test — Conformance Suite
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
📚 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
| Decision | Choice Made | Alternatives | Why |
|---|---|---|---|
| Snapshot format | YAML | JSON, TOML, custom DSL | Human-readable, supports multi-line prompts, widely tooled |
| Scenario scope | Per-feature YAML file (permissions.yaml, hooks_extended.yaml) | One giant file; one tiny file each | Feature-sized files match review granularity; easy to git-blame |
| Event-stream assertion | Normalised equality | Subsequence match; schema-only check | Full equality catches ordering and count drift; normalisation strips non-deterministic fields (timestamps, UUIDs) |
| Harness location | Co-located with each SDK | Central Node-based harness driving all four | Each SDK's harness exercises the SDK's actual API, not a testing shim |
| CLI under test | Same bundled CLI across harnesses | Language-specific mocks | Only way to prove SDK-independent behaviour |
| Non-determinism handling | Normalise at the harness boundary | Deterministic replay; snapshot subsets | Normalisation 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:
- Load scenario — parse YAML into a typed scenario object (prompt, tools[], hookResponses[], expectedEvents[]).
- Spawn SDK Client —
new CopilotClient();await client.start(). - Create session — register tools from scenario, wire
onPermissionRequestto return the scenario's canned approvals/denials. - Subscribe to events — push every received
SessionEventinto anactualarray. - Send prompt —
session.send({prompt: scenario.prompt}). - Await idle —
await session.onIdle(). - Normalise — strip volatile fields:
timestamp,sessionId,messageId, trace-context IDs → placeholder strings. - Diff —
deepStrictEqual(actual, scenario.expectedEvents). Failures print structural diffs. - Teardown —
await session.disconnect();await client.stop(). - Report — JUnit XML or JSON; CI annotates the PR with failures.
Key Components
| Path | Role | Why |
|---|---|---|
test/snapshots/permissions/ | Permission-gate scenarios | Covers accept/deny/unattended paths |
test/snapshots/hooks_extended/ | pre/post tool-use hook scenarios | Prove hook return values influence tool behaviour uniformly |
test/snapshots/session_fs/ | Virtual FS scenarios | Ensures provider interface contract is identical across SDKs |
test/snapshots/commands/ | Slash-command scenarios | Defines how /help, custom commands flow through events |
test/snapshots/system_message/ | System-message replace/customize/transform | The three modes must yield specific event shapes |
test/harness.ts | Node harness | Reference implementation. |
test/harness.py | Python harness | Uses asyncio to drive the SDK in reproducible loop. |
go/internal/e2e/ | Go harness | Standard testing.T-integrated. |
dotnet/test/Harness/ | .NET harness | xUnit-based. |
test/normaliser.ts | Shared (conceptually) normalisation rules | Each SDK re-implements, but rules should match — this is tested by a meta-test. |
test/README.md | Author's guide | How to add a scenario. |
⚡ Normalisation Algorithm
Each harness applies a fixed set of replacements to the event stream before diffing:
sessionId→<SESSION_ID>messageId→<MESSAGE_ID_N>(indexed to preserve cross-references within the stream)traceId,spanId→ redactedtimestamp→ omitted- Numeric durations → omitted (timing varies)
- Tool call IDs →
<TOOL_CALL_ID_N>
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
- Create
test/snapshots/<feature>/my_case.yamlwithscenario:+expectedEvents:sections. - Run Node harness in record mode:
npm run test:record -- my_case. Captures and writes expected events. - Run all four harnesses in check mode:
npm run test:conformance. All must pass. - Submit PR; reviewer sees the YAML change + any generated code updates.
⚠ What Could Go Wrong
| Failure | Cause | Fix |
|---|---|---|
| One SDK diffs; others pass | SDK bug or missed event | Debug the one SDK; its harness prints the diff with event indices |
| All SDKs diff the same way | Snapshot stale (protocol changed) | Re-record; review the delta |
Harness hangs on await idle | SDK never emitted SessionIdleEvent | CLI/SDK bug; harness has a timeout wrapper that fails the test with context |
| Normaliser drops a needed field | Rule too broad | Narrow the rule — assert on what you care about |
| Cross-SDK non-determinism (e.g., tool order) | Underlying CLI reorders | Use 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
| Knob | Where | Purpose |
|---|---|---|
COPILOT_CLI_PATH | env | Override CLI under test (useful for CLI dev) |
RECORD=1 | env | Regenerate expectedEvents instead of asserting |
--filter | CLI flag | Run only matching scenarios |
--timeout | CLI flag | Per-scenario wall-clock cap |
| Normalisation rules | per-harness | Must match across harnesses; meta-tested |
snapshot-dir | harness arg | Alternate snapshot root (for fork testing) |
| JUnit output path | CLI flag | CI annotation integration |
| Verbose diff | CLI flag | Print 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)