The amalgamation and the bindings now ship with sx itself (sx library/vendors/sqlite/ — bindings + c/ amalgamation); every import flips from ../src/db/sqlite.sx to vendors/sqlite/sqlite.sx, resolved through the compiler's stdlib search paths. vendor/ and src/db/ leave this repo entirely. make test 22/22 — the object cache keys on content, not path, so the relocated source still hits the existing cache entries.
175 lines
9.0 KiB
Plaintext
175 lines
9.0 KiB
Plaintext
// Pinned acceptance for P3.4b — failure paths are loud and machine-readable.
|
|
//
|
|
// Drives the BUILT `build/dist` binary (via `process.run`, like
|
|
// publish_persist.sx) through every publish failure class the slice plan
|
|
// names — a malformed manifest, a missing artifact file, an unknown
|
|
// platform id, a declared-size mismatch, and a declared-sha256 mismatch —
|
|
// and asserts the P3.4b contract for each:
|
|
//
|
|
// * exit code 1 (command failed; NOT the parser's EX_USAGE 64),
|
|
// * stdout under `--json` is a SINGLE JSON object
|
|
// `{"status":"error","error":{"code":<dotted code>,"message":...}}`,
|
|
// * nothing is persisted: a fresh store gains no dist.db.
|
|
//
|
|
// The no-partial-state crux is then asserted against a NON-EMPTY store:
|
|
// publish version A successfully, fail version B on a digest mismatch into
|
|
// the SAME store, and require the store state unchanged — one release,
|
|
// channel still pointing at A (no partially-published release, no moved
|
|
// channel pointer). Store state is queried from `<store>/dist.db` via the
|
|
// SQLite bindings.
|
|
#import "modules/std.sx";
|
|
#import "modules/std/json.sx";
|
|
process :: #import "modules/std/process.sx";
|
|
fs :: #import "modules/std/fs.sx";
|
|
sq :: #import "vendors/sqlite/sqlite.sx";
|
|
|
|
STORE :: ".sx-tmp/publish_fail";
|
|
MDIR :: ".sx-tmp/publish_fail_m";
|
|
|
|
// Manifests share the committed 5-byte fixtures; paths resolve relative to
|
|
// the manifest's own directory (MDIR), so ../../examples/fixtures reaches
|
|
// them.
|
|
GOOD_A :: "{\"app\":\"acme-app\",\"version\":\"1.2.3\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"}]}";
|
|
BAD_DIGEST :: "{\"app\":\"acme-app\",\"version\":\"1.2.4\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\",\"sha256\":\"0000000000000000000000000000000000000000000000000000000000000000\"}]}";
|
|
BAD_SIZE :: "{\"app\":\"acme-app\",\"version\":\"1.2.5\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\",\"size\":9999}]}";
|
|
BAD_PLATFORM:: "{\"app\":\"acme-app\",\"version\":\"1.2.6\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"playstation\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"}]}";
|
|
NO_ARTIFACT :: "{\"app\":\"acme-app\",\"version\":\"1.2.7\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"no-such-file.apk\"}]}";
|
|
NOT_JSON :: "this is not a manifest";
|
|
|
|
get :: (o: Object, key: string) -> Value {
|
|
i := 0;
|
|
while i < o.len {
|
|
if o.items[i].key == key { return o.items[i].val; }
|
|
i += 1;
|
|
}
|
|
process.assert(false, concat("missing json key: ", key));
|
|
dummy : Value = .null_;
|
|
return dummy;
|
|
}
|
|
get_str :: (o: Object, key: string) -> string { return get(o, key).str; }
|
|
get_obj :: (o: Object, key: string) -> Object { return get(o, key).object; }
|
|
get_arr :: (o: Object, key: string) -> Array { return get(o, key).array; }
|
|
|
|
publish_cmd :: (mpath: string, store: string) -> string {
|
|
c := concat("build/dist ci publish --manifest ", mpath);
|
|
c = concat(c, concat(" --local-store ", store));
|
|
return concat(c, " --json 2>/dev/null");
|
|
}
|
|
|
|
// One-row scalar queries over `<STORE>/dist.db` ("" = unbound binding).
|
|
db_open_ro :: () -> sq.Sqlite {
|
|
c, oe := sq.Sqlite.open_v2(path_join(STORE, "dist.db"), sq.SQLITE_OPEN_READONLY);
|
|
process.assert(!oe, "dist.db must open as a SQLite database");
|
|
c.busy_timeout(2000);
|
|
return c;
|
|
}
|
|
|
|
q_text :: (sql: string, p1: string) -> string {
|
|
c := db_open_ro();
|
|
st, pe := c.prepare(sql);
|
|
process.assert(!pe, concat("prepare must succeed: ", sql));
|
|
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
|
|
rc, se := st.step();
|
|
process.assert(!se, concat("step must succeed: ", sql));
|
|
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
|
|
out := st.column_text(0);
|
|
st.finalize();
|
|
c.close();
|
|
return out;
|
|
}
|
|
|
|
q_int :: (sql: string, p1: string) -> i64 {
|
|
c := db_open_ro();
|
|
st, pe := c.prepare(sql);
|
|
process.assert(!pe, concat("prepare must succeed: ", sql));
|
|
if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; }
|
|
rc, se := st.step();
|
|
process.assert(!se, concat("step must succeed: ", sql));
|
|
process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql));
|
|
out := st.column_int64(0);
|
|
st.finalize();
|
|
c.close();
|
|
return out;
|
|
}
|
|
|
|
// Write `body` to `path` via the shell (single-quoted, so the JSON's double
|
|
// quotes pass through literally).
|
|
write_file :: (path: string, body: string) {
|
|
cmd := concat(concat(concat("printf '%s' '", body), "' > "), path);
|
|
process.run(cmd);
|
|
}
|
|
|
|
// Run one failing publish and assert the full failure contract: exit 1,
|
|
// stdout is exactly one JSON error object, and its error.code equals
|
|
// `want_code`. `store` distinguishes the per-case fresh stores.
|
|
assert_fails :: (label: string, mpath: string, store: string, want_code: string, scratch: Allocator) {
|
|
r := process.run(publish_cmd(mpath, store));
|
|
process.assert(r != null, concat("spawn failed: ", label));
|
|
res := r!;
|
|
process.assert(res.exit_code == 1, concat("must exit 1 (command failed): ", label));
|
|
|
|
v, e := parse(res.stdout, scratch);
|
|
if e { process.assert(false, concat("stdout must be one JSON object: ", label)); return; }
|
|
o := v.object;
|
|
process.assert(get_str(o, "status") == "error", concat("status must be \"error\": ", label));
|
|
eo := get_obj(o, "error");
|
|
process.assert(get_str(eo, "code") == want_code, concat("error.code mismatch: ", label));
|
|
process.assert(get_str(eo, "message").len > 0, concat("error.message must be non-empty: ", label));
|
|
out(concat(concat(" ", label), ": exit 1 + JSON error ok\n"));
|
|
}
|
|
|
|
main :: () -> i32 {
|
|
gpa := GPA.init();
|
|
arena := Arena.init(xx gpa, 1 << 20);
|
|
defer arena.deinit();
|
|
|
|
process.run(concat("rm -rf ", STORE));
|
|
process.run(concat("rm -rf ", MDIR));
|
|
process.run(concat("mkdir -p ", MDIR));
|
|
|
|
write_file(path_join(MDIR, "good_a.json"), GOOD_A);
|
|
write_file(path_join(MDIR, "bad_digest.json"), BAD_DIGEST);
|
|
write_file(path_join(MDIR, "bad_size.json"), BAD_SIZE);
|
|
write_file(path_join(MDIR, "bad_platform.json"), BAD_PLATFORM);
|
|
write_file(path_join(MDIR, "no_artifact.json"), NO_ARTIFACT);
|
|
write_file(path_join(MDIR, "not_json.json"), NOT_JSON);
|
|
|
|
// ── each failure class: exit 1 + the precise dotted code, into a
|
|
// fresh per-case store that must gain NO database ────────────────
|
|
assert_fails("digest mismatch", path_join(MDIR, "bad_digest.json"), concat(STORE, "-digest"), "validation.digest_mismatch", xx arena);
|
|
assert_fails("size mismatch", path_join(MDIR, "bad_size.json"), concat(STORE, "-size"), "validation.size_mismatch", xx arena);
|
|
assert_fails("unknown platform", path_join(MDIR, "bad_platform.json"), concat(STORE, "-platform"), "manifest.unknown_platform", xx arena);
|
|
assert_fails("missing artifact", path_join(MDIR, "no_artifact.json"), concat(STORE, "-missing"), "manifest.missing_artifact", xx arena);
|
|
assert_fails("malformed manifest",path_join(MDIR, "not_json.json"), concat(STORE, "-badjson"), "manifest.bad_json", xx arena);
|
|
|
|
process.assert(!fs.exists(concat(STORE, "-digest/dist.db")),
|
|
"failed publish into a fresh store must not create dist.db");
|
|
|
|
// ── no-partial-state against a NON-EMPTY store ────────────────────
|
|
// Publish A successfully, then fail B on a digest mismatch into the
|
|
// SAME store: the db must be unchanged (one release, channel → A).
|
|
ra := process.run(publish_cmd(path_join(MDIR, "good_a.json"), STORE));
|
|
process.assert(ra != null, "spawn publish A failed");
|
|
process.assert(ra!.exit_code == 0, "publish A must exit 0");
|
|
|
|
rb := process.run(publish_cmd(path_join(MDIR, "bad_digest.json"), STORE));
|
|
process.assert(rb != null, "spawn failing publish B failed");
|
|
process.assert(rb!.exit_code == 1, "publish B must exit 1 (digest mismatch)");
|
|
|
|
process.assert(q_int("SELECT COUNT(*) FROM releases", "") == 1, "after failed B: db still has ONE release (A)");
|
|
process.assert(q_int("SELECT COUNT(*) FROM channels", "") == 1, "after failed B: one channel");
|
|
process.assert(q_text("SELECT current_release_id FROM channels", "") == "rel-acme-app-1.2.3",
|
|
"after failed B: channel still points at A (no moved pointer)");
|
|
print(" non-empty store: failed publish left the db unchanged\n");
|
|
|
|
process.run(concat("rm -rf ", concat(STORE, "-digest")));
|
|
process.run(concat("rm -rf ", concat(STORE, "-size")));
|
|
process.run(concat("rm -rf ", concat(STORE, "-platform")));
|
|
process.run(concat("rm -rf ", concat(STORE, "-missing")));
|
|
process.run(concat("rm -rf ", concat(STORE, "-badjson")));
|
|
process.run(concat("rm -rf ", STORE));
|
|
process.run(concat("rm -rf ", MDIR));
|
|
print("publish_fail: ALL CASES PASS\n");
|
|
return 0;
|
|
}
|