Skip to content

Auto E2E test run 2026-05-18 - #353

Merged
ANcpLua merged 4 commits into
mainfrom
tests/auto-e2e-2026-05-18
May 18, 2026
Merged

Auto E2E test run 2026-05-18#353
ANcpLua merged 4 commits into
mainfrom
tests/auto-e2e-2026-05-18

Conversation

@ANcpLua

@ANcpLua ANcpLua commented May 18, 2026

Copy link
Copy Markdown
Owner

Summary

First real E2E scenario landed on top of PR #347's bootstrap topology, plus
two pre-existing infrastructure bugs unblocked along the way. POST OTLP/HTTP
JSON to the running qyl-collector container, assert the ingest lands in
DuckDB and is reflected in /api/v1/telemetry/stats. Topology fixture
boots both qyl-collector and qyl-mcp end-to-end for the first time since
#347 wired it up.

Scenario

  • Operator/service -> POSTs an OTLP/HTTP JSON trace with one span
    to qyl-collector (/v1/traces)
  • Expected outcome: 202 Accepted on ingest, and the storage stats
    endpoint (/api/v1/telemetry/stats) reflects the span (spanCount
    increases, newestSpanTime >= the ingest timestamp)

One scenario per class, one [Fact] per class. Joins E2ECollection via
[Collection(E2ECollection.Name)] so the fixture is shared with future
scenarios.

Topology booted

  • qyl-collector:latest (locally built from services/qyl.collector/Dockerfile)
  • qyl-mcp:latest (locally built from services/qyl.mcp/Dockerfile)
  • WireMock LLM stub on a random localhost port (configured in the fixture
    but not exercised by this scenario)
  • Booted via: Testcontainers (ICompositeContainer-style — three containers
    on a fresh qyl-e2e-* Docker network per fixture lifetime). TUnit.Aspire
    not yet adopted in qyl.

Assertions

  • Ingest HTTP response is exactly 202 Accepted
  • /api/v1/telemetry/stats spanCount increases past the pre-ingest baseline
    within 15 s (bounded poll, 250 ms cadence)
  • /api/v1/telemetry/stats newestSpanTime >= the test's start nano timestamp

Refactors for testability (separate commits)

  • c2a4fd18 refactor(tests/e2e): make topology fixture actually boot collector + mcp — three fixes that the bootstrap PR (deps: bump MCP 1.2 -> 1.3 + e2e bootstrap #347) couldn't
    surface because the fixture itself was never executed:
    • Pass QYL_OTLP_AUTH_MODE=Unsecured to collector (default is ApiKey
      in Production and requires API key configuration; collector aborted
      on startup before this).
    • Pass ASPNETCORE_URLS=http://+:5200 to MCP (the Dockerfile hard-codes
      8080, so the previous WithPortBinding(5200, true) mapped to a port
      nothing served).
    • Wait on the real /alive health endpoint instead of "Now listening on:" log scrape.
    • Collapse the dual-ctor pattern in QylTopologyFixture to a single
      public ctor (xUnit v3 ICollectionFixture<T> requires exactly one).

Production fixes shipped (separate commit)

  • bede0b1f fix(docker): bump .NET base images so SDK 10.0.300 actually builds — the pinned mcr.microsoft.com/dotnet/sdk:10.0@sha256:8a90a473...
    shipped SDK 10.0.203, but global.json requires 10.0.300 since PR deps: bump .NET SDK to 10.0.300 + ANcpLua MSBuild SDKs #346.
    Every nuke DockerImageBuild (and therefore the full compose stack and
    the E2E topology) was broken since deps: bump .NET SDK to 10.0.300 + ANcpLua MSBuild SDKs #346 merged — no CI job exercises
    DockerImageBuild, so the regression sat latent. Bumped sdk:10.0,
    aspnet:10.0, sdk:10.0-alpine, and runtime-deps:10.0-alpine to the
    current latest SHAs.
  • Same commit pins qyl.mcp runtime stage to --platform=linux/amd64
    to match its -r linux-musl-x64 cross-compile target. arm64 dev hosts
    (Apple Silicon + OrbStack/Docker Desktop) previously couldn't run the
    image because runtime-deps resolved to arm64, producing
    ld-musl-x86_64.so.1 not found. No-op on amd64 hosts.

Production bug surfaced (NOT fixed here — separate concern)

GET /api/v1/traces is fully broken: schema declares
spans.kind VARCHAR NOT NULL and spans.status_code VARCHAR NOT NULL
(services/qyl.collector/Storage/DuckDbSchema.g.sql:316,319), but
SpanStorageRow.Kind and SpanStorageRow.StatusCode are byte. The
source-generated MapFromReader
(internal/qyl.collector.storage.generators/DuckDbEmitter.cs:219-222)
emits reader.Col(N).GetByte(0), so every span read throws
InvalidCastException: Unable to cast object of type 'System.String' to type 'System.Byte' and the endpoint returns 500.

Repro (against a fresh collector container with QYL_OTLP_AUTH_MODE=Unsecured):

curl -s -X POST http://localhost:5100/v1/traces \
  -H 'Content-Type: application/json' \
  -d '{"resourceSpans":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"x"}}]},"scopeSpans":[{"spans":[{"traceId":"abcdef0123456789abcdef0123456789","spanId":"abcdef0123456789","name":"s","kind":1,"startTimeUnixNano":1779000000000000000,"endTimeUnixNano":1779000000001000000,"status":{"code":1}}]}]}]}'
# 202 Accepted

curl -s http://localhost:5100/api/v1/traces?limit=10
# {"error":"Internal Server Error","traceId":"..."}

Fix is either schema migration to TINYINT (and similarly for
spans.kind) or property type change to string. NOT fixed in this PR
because (a) it's unrelated to E2E coverage, (b) a schema migration needs
its own focused PR with migration-up/down testing and the integration-test
suite extended, and (c) it's the higher-priority next-cycle item — the
routine handoff records this as gap #1.

This scenario asserts via /api/v1/telemetry/stats instead (which works
— it doesn't go through SpanStorageRow.MapFromReader), so the
end-to-end ingest pipeline is verified without depending on the broken
read path.

xUnit -> TUnit migrations

None. Repo standard is xUnit.v3 with xunit.v3.mtp-v2 (verified across
qyl.collector.tests, qyl.collector.integration.tests, qyl.mcp.tests,
and the existing qyl.e2e.tests bootstrap). New scenario matches the
existing style.

Runtime

  • Setup (topology boot): ~4 s (collector ~3 s on /health, MCP ~2 s on /alive)
  • Scenario: ~1 s (POST returns immediately, stats poll converges within ~500 ms)
  • Teardown: ~0.3 s (containers + network deleted)
  • Total per run: ~5-7 s

Stability check

4 consecutive local runs, all passed:

RUN 1: 6s 042ms  passed
RUN 2: 4s 810ms  passed
RUN 3: 4s 604ms  passed
RUN 4 (initial verification): 6s 193ms  passed

Zero flakes.

Mutation: not applicable at the E2E level.

Gaps remaining for next run

  1. Fix the spans VARCHAR-vs-byte read-mapping bug (highest priority — a
    real production data-fetch defect, not an E2E concern). Once fixed,
    extend this scenario to also assert via GET /api/v1/traces/{traceId}
    for a true read+write roundtrip.
  2. Second E2E scenario: MCP -> collector handshake. The MCP container
    now boots; drive an MCP tool that reads collector data (POST OTLP
    first, then invoke an MCP search tool over JSON-RPC, assert the span
    is returned).
  3. Third E2E scenario: chat ingest -> trace at sink with credentials
    redacted
    — the original handoff candidate. Larger surface (needs a
    sink container plus LLM wiring); do after Add Claude Code GitHub Workflow #1 and refactor: internal OTLP types + remove ghost code #2.

🤖 Generated with Claude Code

ANcpLua and others added 4 commits May 18, 2026 03:09
The pinned `mcr.microsoft.com/dotnet/sdk:10.0@sha256:8a90a473...` image
ships SDK 10.0.203, but global.json requires 10.0.300 (PR #346). Every
`nuke DockerImageBuild` (and therefore the full compose stack and the
E2E topology fixture) has been broken since #346 merged — there's no CI
job that exercises DockerImageBuild, so the regression sat latent.

Bumps:
  - dotnet/sdk:10.0           -> sha256:dc8430e6024d... (10.0.300)
  - dotnet/aspnet:10.0        -> sha256:9b5222b0ff8e... (10.0.300)
  - dotnet/sdk:10.0-alpine    -> sha256:5c559aa5d993... (10.0.300)
  - dotnet/runtime-deps:10.0-alpine -> sha256:f276c0256ffc...

qyl.mcp Dockerfile additionally pins the runtime stage to
`--platform=linux/amd64`. Stage 1 cross-compiles with
`-r linux-musl-x64`, but on arm64 hosts the runtime-deps multi-arch
image resolves to arm64, producing an unbootable image
(`ld-musl-x86_64.so.1 not found`). Pinning the runtime stage matches
the cross-compiled binary's RID and is a no-op on amd64 hosts (where
the runtime stage was implicitly amd64 anyway).

Verified: `docker build -f services/qyl.{collector,mcp}/Dockerfile`
both succeed; collector container returns 200 on /health; mcp
container returns "Healthy" on /alive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bootstrap PR (#347) wired QylTopologyFixture but never executed it
— only the WireMock seam test (Category=E2EBootstrap, no Docker) ran.
Two latent defects in the fixture surface immediately on first real
boot:

1. **Collector**: `WebApplication.CreateSlimBuilder(args)` defaults
   `ASPNETCORE_ENVIRONMENT` to `Production`, where
   `QYL_OTLP_AUTH_MODE` defaults to `ApiKey` (see
   `CollectorAuthExtensions.cs:22`). The collector then throws at
   startup because no `QYL_OTLP_PRIMARY_API_KEY` is set, and the
   /health wait strategy times out. Fix: set
   `QYL_OTLP_AUTH_MODE=Unsecured`.

2. **MCP**: The Dockerfile hard-codes
   `ENV ASPNETCORE_URLS=http://+:8080`, so the container listens on
   8080 internally while the fixture binds 5200. The previous
   "Now listening on:" log-message wait succeeds (Kestrel logs it for
   port 8080), but `GetMappedPublicPort(5200)` then refers to a port
   nothing serves. Fix: override `ASPNETCORE_URLS` to 5200 to match
   the binding, and wait on the real `/alive` health endpoint instead
   of the log message.

Also collapses the two-constructor pattern in QylTopologyFixture into
a single one (xUnit v3 collection fixtures require exactly one public
constructor — the dual ctors made the existing
`ICollectionFixture<QylTopologyFixture>` registration throw
`XunitException` at test discovery time).

Verified by booting the topology end-to-end and POSTing to
`/v1/traces` on the collector — the ingest path now returns 202 and
the stored span is visible via `/api/v1/telemetry/stats`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…orage

First real E2E scenario on top of the topology fixture from PR #347.
Drives `POST /v1/traces` on the running qyl-collector container with a
single-span OTLP/HTTP JSON payload (camelCase, hex-encoded ids,
unique service.name per run for isolation), asserts the response is
202 Accepted, then polls `GET /api/v1/telemetry/stats` (bounded 15s,
250ms cadence) until `spanCount` increases past the pre-ingest
baseline.

Exercises the full pipeline inside one real container:
HTTP receiver -> JSON parse -> OtlpConverter ->
SpanRingBuffer.PushRange -> DuckDbStore.EnqueueAsync ->
GetStorageStatsAsync.

The richer roundtrip target (`GET /api/v1/traces`) is intentionally
NOT asserted on because it currently returns HTTP 500 — the spans
schema declares `kind VARCHAR NOT NULL` and `status_code VARCHAR NOT
NULL` (DuckDbSchema.g.sql:316,319) but `SpanStorageRow.Kind` and
`SpanStorageRow.StatusCode` are `byte`. The source-generated
`MapFromReader` emits `reader.Col(N).GetByte(0)`, so every span read
throws `InvalidCastException: Unable to cast object of type
'System.String' to type 'System.Byte'`. That is a pre-existing
production bug to fix in a follow-up; the comment on the test class
records the repro so the gap is discoverable.

Stability: ran 4x locally, all pass in 4-7s per run. Class fixture
shared across scenarios via [Collection(E2ECollection.Name)] so the
topology is re-used by future test classes that join the collection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Top-of-file entry summarizes the three commits on
tests/auto-e2e-2026-05-18 (Dockerfile bumps, fixture testability fix,
first real scenario), the verification results, and the production
bug surfaced for follow-up: the spans VARCHAR-vs-byte read-mapping
mismatch that breaks GET /api/v1/traces. Next-cycle handoff lists
that bug fix as priority 1 ahead of additional scenarios.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ANcpLua
ANcpLua merged commit d1a1826 into main May 18, 2026
1 check passed
@ANcpLua
ANcpLua deleted the tests/auto-e2e-2026-05-18 branch May 18, 2026 01:19
ANcpLua added a commit that referenced this pull request May 23, 2026
…ill builds

The qyl.e2e.tests project was added in PR #353 and verified Debug-mode
only. Three regressions accumulated between then and today blocked the
Release build (which is what `nuke E2ETests` and `dotnet test -c Release`
use):

  - Testcontainers deprecated the parameterless `ContainerBuilder()` ctor
    in favor of `ContainerBuilder("image-name")` (CS0618). The image is
    now passed positionally instead of via `.WithImage(...)`.
  - `DateTimeOffset.UtcNow` and `DateTime.UtcNow` in the OTLP scenario fire
    AL0026 / RS0030 in Release. The qyl convention is
    `TimeProvider.System.GetUtcNow()` everywhere outside low-level perf
    code.
  - `INetwork _network` was deleted via `DeleteAsync()` but never disposed,
    leaving the unmanaged handle (CA2213). `DisposeAsync()` performs the
    network delete and releases the handle.

Also NoWarn `MultipleGlobalAnalyzerKeys` — same worktree-noise NoWarn the
`Qyl.OpenTelemetry.SemanticConventions.SourceGeneration.Generator` csproj
already carries: the parent `.globalconfig` and the worktree's identical
copy are both ancestors of the project file, MSBuild unsets the duplicate
keys and emits a per-key diagnostic. Harmless; not a real codebase issue.

Verified: `dotnet build tests/qyl.e2e.tests -c Release` is 0 errors, 0
warnings. The existing OtlpHttpTraceIngestionRoundtripTests scenario still
passes 3 consecutive runs against a freshly built collector+mcp topology.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ANcpLua added a commit that referenced this pull request May 23, 2026
Adds the second real E2E scenario on the qyl topology fixture and the
first one that exercises qyl-mcp directly. Until now the MCP container
was booted by the fixture but no scenario hit it — its only assurance
was the `/alive` health probe, which proves nothing about the source-
generator-emitted tool catalog or skill bundle loading.

## Scenario

- Actor: an AI-agent connector (Anthropic, OpenAI, custom) or an
  operator validating a fresh deploy.
- Flow: GET /llms.txt against the running qyl-mcp container.
- Expected outcome: 200 OK, text/plain, body advertises the qyl heading,
  the server summary, the documented Streamable HTTP transport, a tool
  count >= 1, a capability count >= 1, the documented discovery tools
  (`qyl.list_capabilities`, `qyl.get_capability_guide`), and the
  enabled-capabilities section header.

The `[1-9][0-9]*` regex on tool/capability counts catches the failure
mode source generators most often produce — a zero-count catalog from a
trimmer regression or a skipped `nuke Generate` step would silently pass
any `Contains("Tool count:")` substring check.

One scenario per class, one [Fact]. Joins `E2ECollection` so the shared
fixture is reused; topology setup remains ~4 s + ~1 s per scenario.

## Stability

3 consecutive Release-mode runs (`dotnet test ... -c Release`), all
green: 7.5 s cold + 5.0 s + 5.0 s. Zero flakes. Mutation: not applicable
at the E2E level.

## Gaps remaining for next routine run

1. Spans VARCHAR-vs-byte read-mapping bug (carry-forward from PR #353)
   still blocks `GET /api/v1/traces`. Higher-priority data-correctness
   defect, but outside the E2E routine's scope — should land via the
   integration-tests routine first, then this suite can extend to assert
   the full ingest+read roundtrip.
2. MCP JSON-RPC scenario: drive `initialize` + `tools/list` over the
   Streamable HTTP `/mcp` endpoint, not just the static `/llms.txt`
   projection. Will exercise the actual MCP transport machinery.
3. qyl-loom is in `eng/compose.yaml` and built by the Nuke
   `DockerImageBuild` target but absent from the topology fixture.
   Adding it requires an OPENAI_API_KEY stub via the existing WireMock
   LLM seam.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant