omnigraph/crates/omnigraph-cli/tests/parity_matrix.rs
Andrew Altshuler 45500a690a
refactor(cli): collapse export + graphs-list onto GraphClient (RFC-009 Phase 3c) (#213)
The last two embedded-vs-remote forks move onto the enum, so every such
`if` in the CLI now lives in client.rs — the point of the refactor.

- `export<W: Write>`: the streaming verb 3b deferred (writes to a writer,
  chunks the HTTP response body, rather than returning a DTO). Embedded
  calls db.export_jsonl_to_writer; Remote streams the chunked body through.
  Opens WITHOUT policy (like reads), so it routes via resolve().
- `list_graphs`: remote-only by design (no local enumeration endpoint), so
  the Embedded arm keeps the loud "requires a remote multi-graph server"
  bail verbatim. Routing it through the enum still buys the shared
  resolve() addressing/token preamble the arm hand-rolled.

Retire the now-orphaned execute_export_to_writer /
execute_export_remote_to_writer pair, and sweep two pre-existing dead fns
while in the files: inferred_config_path (helpers.rs) and yaml_string
(output.rs, shadowed by test-local copies).

parity_matrix gains one row, parity_export — the single intended matrix
change in this phase. Export is a JSONL stream, not a single --json doc,
so it compares the two arms' output line-wise (sorted; twin graphs are
byte-copies so rows need no scrubbing). graphs-list gets no row: its
remote-only behavior is a documented exclusion, not an equality case.

Full workspace tests pass; all 12 parity rows green.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 21:03:45 +03:00

284 lines
8.7 KiB
Rust

//! RFC-009 Phase 1 — the embedded/remote parity referee.
//!
//! For every CLI verb with an `is_remote` fork, run the identical
//! invocation against (a) the local graph directly and (b) a spawned
//! server on a twin copy of the same graph, with the SAME actor on both
//! arms (local `--as act-parity`; remote bearer token resolving to
//! `act-parity`). Scrub the declared-volatile allowlist
//! (`support::scrub_volatile` — ids, wall-clock, transport locations);
//! everything else must match exactly.
//!
//! This test PINS behavior; it does not idealize it. Genuine divergences
//! discovered here are recorded in `KNOWN_DIVERGENCES` below (and filed),
//! never silently repaired — repairs are Phase 3's job, gated by this
//! referee staying green through the refactor.
use tempfile::TempDir;
mod support;
use support::*;
/// Divergences between the arms that exist today, pinned as expectations.
/// Removing an entry requires the corresponding behavior change to be a
/// deliberate, release-noted decision (RFC-009 Compatibility).
const KNOWN_DIVERGENCES: &[&str] = &[
// populated by the rows below as they are written
];
/// One matched setup per row: twin graphs + the SAME Cedar bundle on both
/// arms (the local arm via --config top-level policy.file; the server via
/// its config). Returns everything a row needs.
struct Parity {
_temp: TempDir,
local: std::path::PathBuf,
local_cfg: std::path::PathBuf,
server: TestServer,
}
fn parity() -> Parity {
let (temp, local, remote) = twin_graphs();
let (local_cfg, server_cfg) = parity_configs(temp.path(), &local, &remote);
let server = spawn_server_with_config_env(
&server_cfg,
&[(
"OMNIGRAPH_SERVER_BEARER_TOKENS_JSON",
r#"{"act-parity":"parity-tok"}"#,
)],
);
Parity {
_temp: temp,
local,
local_cfg,
server,
}
}
impl Parity {
fn run(&self, args: &[&str]) -> (std::process::Output, std::process::Output) {
run_both_with_config(&self.local, Some(&self.local_cfg), &self.server.base_url, args)
}
}
fn assert_parity(verb: &str, local: &std::process::Output, remote: &std::process::Output) {
assert_eq!(
local.status.code(),
remote.status.code(),
"{verb}: exit codes diverge\nlocal: {local:?}\nremote: {remote:?}"
);
if local.status.success() {
let local_json = scrubbed_json(local);
let remote_json = scrubbed_json(remote);
assert_eq!(
local_json, remote_json,
"{verb}: scrubbed JSON diverges (left=local, right=remote)"
);
}
}
#[test]
fn parity_query() {
let p = parity();
let query = fixture("test.gq");
let (l, r) = p.run(&[
"query",
"--query",
query.to_str().unwrap(),
"--name",
"get_person",
"--params",
r#"{"name":"Alice"}"#,
"--json",
],
);
assert_parity("query", &l, &r);
}
#[test]
fn parity_schema_show() {
let p = parity();
let (l, r) = p.run(&["schema", "show", "--json"]);
assert_parity("schema show", &l, &r);
}
#[test]
fn parity_snapshot() {
let p = parity();
let (l, r) = p.run(&["snapshot", "--json"]);
assert_parity("snapshot", &l, &r);
}
#[test]
fn parity_branch_list() {
let p = parity();
let (l, r) = p.run(&["branch", "list", "--json"]);
assert_parity("branch list", &l, &r);
}
#[test]
fn parity_commit_list() {
let p = parity();
let (l, r) = p.run(&["commit", "list", "--json"]);
assert_parity("commit list", &l, &r);
}
#[test]
fn parity_mutate() {
let p = parity();
let (l, r) = p.run(&[
"mutate",
"-e",
"query add($name: String, $age: I32) { insert Person { name: $name, age: $age } }",
"--params",
r#"{"name":"Parity","age":7}"#,
"--json",
],
);
assert_parity("mutate", &l, &r);
}
#[test]
fn parity_branch_create_delete() {
let p = parity();
let (l, r) = p.run(&["branch", "create", "--from", "main", "parity-branch", "--json"],
);
assert_parity("branch create", &l, &r);
let (l, r) = p.run(&["branch", "delete", "parity-branch", "--json"],
);
assert_parity("branch delete", &l, &r);
}
#[test]
fn parity_branch_merge() {
let p = parity();
let (l, r) = p.run(&["branch", "create", "--from", "main", "feature", "--json"],
);
assert_parity("branch create (merge setup)", &l, &r);
let (l, r) = p.run(&["branch", "merge", "feature", "--into", "main", "--json"],
);
assert_parity("branch merge", &l, &r);
}
#[test]
fn parity_load() {
let p = parity();
let data = p.local.parent().unwrap().join("rows.jsonl");
std::fs::write(
&data,
"{\"type\":\"Person\",\"data\":{\"name\":\"Loaded\",\"age\":1}}\n",
)
.unwrap();
let (l, r) = p.run(&[
"load",
"--mode",
"merge",
"--data",
data.to_str().unwrap(),
"--json",
],
);
assert_parity("load", &l, &r);
}
#[test]
fn parity_export() {
let p = parity();
let (l, r) = p.run(&["export"]);
// export emits a JSONL STREAM, not a single `--json` document, so the
// scrubbed-single-doc `assert_parity` doesn't apply — compare line-wise.
// The twin graphs are byte-copies of one loaded fixture, so rows carry
// identical ids/versions and need no scrubbing; sort the lines so any
// cross-arm row-ordering difference doesn't masquerade as a divergence.
assert_eq!(
l.status.code(),
r.status.code(),
"export: exit codes diverge\nlocal {l:?}\nremote {r:?}"
);
assert!(l.status.success(), "export local arm failed: {l:?}");
let mut local_lines: Vec<&str> = std::str::from_utf8(&l.stdout).unwrap().lines().collect();
let mut remote_lines: Vec<&str> = std::str::from_utf8(&r.stdout).unwrap().lines().collect();
assert!(
!local_lines.is_empty(),
"export produced no rows — the parity check would be vacuous"
);
local_lines.sort_unstable();
remote_lines.sort_unstable();
assert_eq!(
local_lines, remote_lines,
"export: JSONL streams diverge (left=local, right=remote)"
);
}
// ---- error parity: exit codes must match for shared failure cases ----
#[test]
fn parity_errors_share_exit_codes() {
let p = parity();
// unknown branch on merge
let (l, r) = p.run(&["branch", "merge", "no-such-branch", "--into", "main", "--json"],
);
assert_eq!(
(l.status.success(), r.status.success()),
(false, false),
"merge of unknown branch must fail on both arms\nlocal {l:?}\nremote {r:?}"
);
// unknown query name in the source
let query = fixture("test.gq");
let (l, r) = p.run(&[
"query",
"--query",
query.to_str().unwrap(),
"--name",
"no_such_query",
"--json",
],
);
assert_eq!(
(l.status.success(), r.status.success()),
(false, false),
"unknown query name must fail on both arms\nlocal {l:?}\nremote {r:?}"
);
// Discovery (parity HOLDS, behavior surprising): an inline query run
// with a declared-but-unbound param does NOT error on either arm — it
// returns every row (the filter drops), while the stored-query invoke
// path hard-errors 'parameter not provided'. Pinned here as agreeing
// behavior; the cross-path asymmetry is filed separately.
let (l, r) = p.run(&[
"query",
"--query",
query.to_str().unwrap(),
"--name",
"get_person",
"--json",
],
);
assert_eq!(
(l.status.success(), r.status.success()),
(true, true),
"unbound-param inline query currently SUCCEEDS on both arms (matches-all)"
);
}
// ---- documented exclusions (not bugs; the Phase 4 capability table) ----
//
// - `graphs list`: server-only today; becomes Both-capability when the
// embedded arm enumerates the cluster catalog (RFC-009 open Q3, answered).
// - `ingest`: deprecated alias of load; the remote `load` arm itself rides
// the deprecated /ingest route today (RFC-009 Phase 5 flips it to /load —
// this matrix's `parity_load` row is where that flip becomes visible).
// - `init`, `optimize`, `repair`, `cleanup`, `cluster *`: storage-plane by
// design (must work with the server down); Phase 4 declares this.
#[allow(dead_code)]
const EXCLUSIONS_DOCUMENTED: () = ();
#[test]
fn known_divergences_ledger_is_current() {
// The ledger exists so removals are deliberate: an empty list with all
// rows green means the arms agree everywhere the matrix looks.
assert!(
KNOWN_DIVERGENCES.is_empty(),
"divergences are pinned: {KNOWN_DIVERGENCES:?}"
);
}