Skip to content

Latest commit

 

History

History
255 lines (182 loc) · 8.24 KB

File metadata and controls

255 lines (182 loc) · 8.24 KB

observer-c

Minimal C-facing provider library for Observer.

If you are new to this surface, start with HOWTO.md before reading the individual snippets and starters.

The best-DX path now steals shamelessly from Criterion:

  • Test("suite", "human title") { ... }
  • TrackedTest("suite", "human title", "stable/id") { ... }
  • observe_assert(...)
  • observe_invariant(...)
  • observe_assert_eq(...)
  • observe_assert_neq(...)
  • observe_assert_not_null(...)
  • observe_assert_str_eq(...)

Compatibility aliases for the cr_* names currently remain available, but observe_* is now the primary surface.

Compatibility aliases for TestTitle(...), TestTitleId(...), and TestId(...) currently remain available, but the primary documented path is now string-based Test(...) plus optional TrackedTest(...).

observe_assert(...) and observe_invariant(...) are intentionally not identical in meaning:

  • observe_assert(...) expresses a claim about the behavior under test
  • observe_invariant(...) expresses a required condition of the test setup, harness state, or protocol assumptions

They currently share the same failure path in implementation, but they are separate semantic categories and should stay that way.

Underneath that familiar syntax, Observer still enforces its own rules:

  • tests auto-register at process startup
  • deterministic materialization lowers registered tests into explicit registrations
  • registry validation rejects duplicates
  • deterministic sorting is by canonical name, then target
  • observation is bounded and opt-in

The intended build flag is -D__OBSERVE__.

With __OBSERVE__ defined:

  • tests register automatically
  • host helpers are active
  • OBSERVER_HOST_MAIN(...) and dispatch helpers serve list, run, and observe

Without __OBSERVE__ defined:

  • test declarations still compile as ordinary C code
  • no tests are registered automatically
  • host dispatch returns a disabled diagnostic if called

Projects that want stricter exclusion can still wrap their own integration points with #ifdef __OBSERVE__ or compile test translation units only in observe builds.

The library does not guess names or targets from C symbols or runtime reflection.

Files

  • HOWTO.md: detailed user manual covering authoring, __OBSERVE__ modes, host transport, inventory derivation, and end-to-end workflow
  • observer.h: public API
  • observer.c: implementation
  • example_smoke.c: tiny compile-check example
  • host_example.c: tiny list/run host example
  • host_main_example.c: explicit main(argc, argv) handoff to observer_host_main(...)
  • host_mixed_example.c: app-owned CLI example that routes only observe ...
  • starter/: runnable project-shaped example with Makefile, provider host build, inventory derivation, suite run, and snapshot verification
  • starter-failure/: runnable failing companion showing the same provider flow with one intentionally failing exported test

If you want the full end-to-end workflow rather than isolated snippets, start with starter/ and then compare it with starter-failure/.

Minimal Shape

#include "observer.h"

int sum(int a, int b) {
    return a + b;
}

Test("math", "adds two numbers") {
    observe_assert_eq(sum(2, 3), 5);
}

TrackedTest("math", "handles negatives", "math/handles-negatives") {
    observe_assert_eq(sum(-2, 1), -1);
}

That authored form auto-registers, then materializes deterministically into explicit provider registrations before list or run consumes them.

If a test needs a refactor-stable identity, it can opt into id explicitly:

TrackedTest("math", "adds two numbers", "math/adds-two-numbers") {
    observe_assert_eq(sum(2, 3), 5);
}

If a test wants to emit observational data, it uses the existing context helpers directly:

Test(api, healthcheck) {
    static const double latency[] = {1000.0, 1100.0, 980.0};

    observe_metric("wall_time_ns", 104233.0);
    observe_vector("request_latency_ns", latency, 3u);
    observe_tag("resource_path", "fixtures/config.json");
    observe_invariant(client != NULL);
    observe_assert(status_code == 200);
}

Validation Rules

  • derived suite/title identity must be non-empty unless explicit id is provided
  • explicit id, when present, must be non-empty
  • function must be non-null
  • resolved canonical names must be unique
  • resolved targets must be unique

In this first cut, the resolved identity is used for both canonical name and target.

The default derived identity is suite :: title. Duplicate suite/title pairs are hard errors.

This means the common C path is now aligned with the TypeScript shape at the semantic level:

  • human title first
  • optional stable id
  • deterministic lowering before host exposure

Compile Check

cc -D__OBSERVE__ -std=c11 -Wall -Wextra -pedantic lib/c/example_smoke.c lib/c/observer.c -o /tmp/observer-c-smoke
/tmp/observer-c-smoke

Host Example

The library now owns the standard provider host transport, including:

  • list command handling
  • run command handling
  • branded observe alias handling for the same execution path
  • JSON emission
  • base64 encoding
  • target lookup and registry materialization

That means a direct provider host binary can stay nearly trivial:

#include "observer.h"

TrackedTest("pkg", "smoke test", "pkg::smoke") {
    static const char out[] = "ok\n";

    write_stdout(out, sizeof(out) - 1u);
    observe_invariant(1);
}

OBSERVER_HOST_MAIN("c")
cc -D__OBSERVE__ -std=c11 -Wall -Wextra -pedantic lib/c/host_example.c lib/c/observer.c -o /tmp/observer-c-host
/tmp/observer-c-host list
/tmp/observer-c-host observe --target pkg::smoke --timeout-ms 1000
/tmp/observer-c-host run --target pkg::smoke --timeout-ms 1000

If you do not want the macro-owned entrypoint, the same direct-host behavior can be written explicitly in host_main_example.c:

int main(int argc, char **argv) {
#if defined(__OBSERVE__)
    return observer_host_main("c", argc, argv);
#else
    (void)argc;
    (void)argv;
    return 2;
#endif
}

For developer-facing usage, prefer observe. run remains the canonical provider protocol subcommand used by Observer itself for compatibility with the standardized outer contract.

Own Main Integration

If a project wants to keep its own main() and only route Observer commands when the observe build flag is enabled, it can do that too:

#include "observer.h"

#include <stdio.h>

TrackedTest("pkg", "embedded smoke test", "pkg::embedded-smoke") {
    static const char out[] = "ok\n";

    write_stdout(out, sizeof(out) - 1u);
    observe_invariant(1);
}

static int app_main(int argc, char **argv) {
    (void)argc;
    (void)argv;
    fputs("app main\n", stdout);
    return 0;
}

int main(int argc, char **argv) {
#if defined(__OBSERVE__)
    if (argc > 1 && strcmp(argv[1], "observe") == 0) {
        return observer_host_dispatch_embedded("c", "observe", argc, argv);
    }
#endif

    return app_main(argc, argv);
}

Compile that path with:

cc -D__OBSERVE__ -std=c11 -Wall -Wextra -pedantic lib/c/host_embed_example.c lib/c/observer.c -o /tmp/observer-c-embed
/tmp/observer-c-embed observe list
/tmp/observer-c-embed observe --target pkg::embedded-smoke --timeout-ms 1000

This embedded form is the preferred story when an application already uses subcommands like run for its own CLI. The top-level app command becomes observe, and the Observer library handles the rest.

If you want a clearer mixed-argv example where the app still owns its own commands and hands off only observe, see host_mixed_example.c.

Suite Authoring Trap

One common mistake is to copy the lowest-friction starter shape and then restate expect exit = 0 one exact test at a time.

If all you mean is "everything under this selector should pass", do not memcopy exact-name items.

Use one selector-level suite item instead:

test prefix: "glc/" timeoutMs: 1000: expect exit = 0.

If you want the same idea in the full surface, use one inventory-driven iterator instead:

(prefix: "glc/") forEach: [ :name |
    (run: name timeoutMs: 1000) ifOk: [ :r |
        expect: (r exit) = 0.
    ] ifFail: [ :f |
        expect: Fail msg: "inventory run failed".
    ].
].

Only add exact-name items when they assert something extra, such as stdout, stderr, telemetry, or a different timeout.