358 lines
14 KiB
Bash
Executable File
358 lines
14 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)"
|
|
SCENARIO_ROOT="$ROOT/.agents/skills/blacksite-native-qa/references/scenarios"
|
|
STATE_ROOT="$ROOT/.codex/session"
|
|
EVIDENCE_ROOT="$ROOT/.codex/evidence"
|
|
|
|
usage() {
|
|
echo "usage: $0 plan|launch|attach|capture|record|status|restore|close <scenario-name> [arguments]" >&2
|
|
exit 2
|
|
}
|
|
|
|
[[ $# -ge 2 ]] || usage
|
|
ACTION="$1"
|
|
NAME="$2"
|
|
SCENARIO="$SCENARIO_ROOT/$NAME.yaml"
|
|
[[ -f "$SCENARIO" ]] || { echo "native-qa: unknown scenario $NAME" >&2; exit 2; }
|
|
|
|
field() {
|
|
local key="$1"
|
|
sed -n "s/^${key}:[[:space:]]*//p" "$SCENARIO" | head -n 1
|
|
}
|
|
|
|
LANE="$(field build_lane)"
|
|
[[ -n "$LANE" ]] || LANE="dev"
|
|
PACKAGE="$(field package)"
|
|
[[ -n "$PACKAGE" ]] || PACKAGE="editor"
|
|
BINARY_NAME="$(field binary)"
|
|
[[ -n "$BINARY_NAME" ]] || BINARY_NAME="editor"
|
|
FIXTURE="$(field fixture)"
|
|
mkdir -p "$STATE_ROOT" "$EVIDENCE_ROOT"
|
|
|
|
lane_json() {
|
|
python "$ROOT/scripts/codex/cargo_lane.py" env "$LANE" --json
|
|
}
|
|
|
|
list_section() {
|
|
local section="$1"
|
|
awk -v section="$section" '
|
|
$0 == section ":" { active = 1; next }
|
|
active && /^[^[:space:]]/ { exit }
|
|
active && /^[[:space:]]+-[[:space:]]+/ {
|
|
sub(/^[[:space:]]+-[[:space:]]+/, "")
|
|
print
|
|
}
|
|
' "$SCENARIO"
|
|
}
|
|
|
|
snapshot_fixture() {
|
|
local record="$STATE_ROOT/native-$NAME.json"
|
|
local snapshot="$STATE_ROOT/native-$NAME.fixture.snapshot"
|
|
python - "$ROOT" "$FIXTURE" "$record" "$snapshot" "$LANE" "$SCENARIO" <<'PY'
|
|
import hashlib, json, os, pathlib, shutil, sys, time
|
|
|
|
root = pathlib.Path(sys.argv[1]).resolve()
|
|
fixture_text, record_text, snapshot_text, lane, scenario = sys.argv[2:]
|
|
record = pathlib.Path(record_text)
|
|
snapshot = pathlib.Path(snapshot_text)
|
|
if record.is_file():
|
|
previous = json.loads(record.read_text(encoding="utf-8"))
|
|
pid = previous.get("pid")
|
|
if pid:
|
|
try:
|
|
os.kill(int(pid), 0)
|
|
except ProcessLookupError:
|
|
pass
|
|
except PermissionError:
|
|
raise SystemExit(f"native-qa: cannot verify existing runner PID {pid}; refusing launch")
|
|
else:
|
|
raise SystemExit(f"native-qa: existing runner PID {pid} is still active")
|
|
old_fixture = previous.get("fixture")
|
|
if old_fixture and not old_fixture.get("restored"):
|
|
raise SystemExit("native-qa: restore the previous fixture before launching this scenario again")
|
|
record.unlink()
|
|
snapshot.unlink(missing_ok=True)
|
|
data = {
|
|
"pid": None,
|
|
"lane": lane,
|
|
"scenario": scenario,
|
|
"spawned_by_runner": False,
|
|
"prepared_at": time.time(),
|
|
"assertions": {},
|
|
"evidence": [],
|
|
}
|
|
if fixture_text:
|
|
fixture = (root / fixture_text).resolve()
|
|
if fixture != root and root not in fixture.parents:
|
|
raise SystemExit(f"fixture escapes workspace: {fixture}")
|
|
if fixture.exists() and not fixture.is_file():
|
|
raise SystemExit(f"fixture is not a regular file: {fixture}")
|
|
existed = fixture.is_file()
|
|
baseline = hashlib.sha256(fixture.read_bytes()).hexdigest() if existed else None
|
|
if existed:
|
|
shutil.copy2(fixture, snapshot)
|
|
data["fixture"] = {
|
|
"path": str(fixture),
|
|
"existed": existed,
|
|
"baseline_sha256": baseline,
|
|
"snapshot": str(snapshot) if existed else None,
|
|
"restored": False,
|
|
}
|
|
record.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
PY
|
|
}
|
|
|
|
case "$ACTION" in
|
|
plan)
|
|
LANE_JSON="$(lane_json)" PACKAGE="$PACKAGE" BINARY_NAME="$BINARY_NAME" python - "$SCENARIO" "$FIXTURE" <<'PY'
|
|
import json, os, pathlib, sys
|
|
layout = json.loads(os.environ["LANE_JSON"])
|
|
scenario = pathlib.Path(sys.argv[1])
|
|
fixture = sys.argv[2]
|
|
binary = pathlib.Path(layout["target_dir"]) / layout["profile_dir"] / os.environ["BINARY_NAME"]
|
|
print(f"PASS native-qa-plan — {scenario.stem}")
|
|
print(f"Lane: {layout['lane']} ({layout['mode']})")
|
|
print(f"Package: {os.environ['PACKAGE']}")
|
|
print(f"Binary: {binary}")
|
|
print(f"Runtime deps: {layout['runtime_deps']}")
|
|
print(f"Fixture: {fixture or 'scenario-defined'}")
|
|
print("Visual interaction remains manual/user-controlled until explicitly delegated.")
|
|
PY
|
|
echo "Purpose: $(field purpose)"
|
|
echo "Preconditions: $(field preconditions)"
|
|
echo "Steps:"
|
|
list_section steps | nl -w2 -s'. '
|
|
echo "Assertions:"
|
|
list_section assertions | nl -w2 -s'. '
|
|
echo "Evidence: $(field evidence)"
|
|
echo "Cleanup: $(field cleanup)"
|
|
;;
|
|
launch)
|
|
snapshot_fixture
|
|
python "$ROOT/scripts/codex/build_storage.py" enforce --phase pre
|
|
python "$ROOT/scripts/codex/summarize_command.py" \
|
|
--gate "native-$NAME-build" \
|
|
--log "$ROOT/.codex/logs/native-$NAME-build.log" \
|
|
--json-result "$ROOT/.codex/logs/native-$NAME-build.json" \
|
|
-- python "$ROOT/scripts/codex/cargo_lane.py" exec "$LANE" -- cargo build -p "$PACKAGE" --bin "$BINARY_NAME"
|
|
python "$ROOT/scripts/codex/build_storage.py" enforce --phase post
|
|
LAUNCH_ARGS=()
|
|
if [[ "$BINARY_NAME" = "editor" ]]; then
|
|
LAUNCH_ARGS=(--project "$ROOT")
|
|
fi
|
|
nohup python "$ROOT/scripts/codex/cargo_lane.py" run "$LANE" -- \
|
|
"target/debug/$BINARY_NAME" "${LAUNCH_ARGS[@]}" \
|
|
>"$ROOT/.codex/logs/native-$NAME-launch.log" 2>&1 &
|
|
PID=$!
|
|
python - "$STATE_ROOT/native-$NAME.json" "$PID" <<'PY'
|
|
import json, pathlib, sys, time
|
|
path = pathlib.Path(sys.argv[1])
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
data.update({"pid": int(sys.argv[2]), "spawned_at": time.time(), "spawned_by_runner": True})
|
|
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
PY
|
|
echo "PASS native-qa-launch — $NAME — PID $PID"
|
|
;;
|
|
attach)
|
|
[[ $# -eq 3 ]] || usage
|
|
PID="$3"
|
|
kill -0 "$PID" 2>/dev/null || { echo "native-qa: PID $PID is not running" >&2; exit 2; }
|
|
CMDLINE="$(tr '\0' ' ' <"/proc/$PID/cmdline")"
|
|
[[ "$CMDLINE" == *"$BINARY_NAME"* ]] || { echo "native-qa: refusing unexpected process: $CMDLINE" >&2; exit 2; }
|
|
snapshot_fixture
|
|
python - "$STATE_ROOT/native-$NAME.json" "$PID" <<'PY'
|
|
import json, pathlib, sys, time
|
|
path = pathlib.Path(sys.argv[1])
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
data.update({"pid": int(sys.argv[2]), "attached_at": time.time(), "spawned_by_runner": False})
|
|
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
PY
|
|
echo "PASS native-qa-attach — $NAME — PID $PID"
|
|
;;
|
|
capture)
|
|
[[ $# -eq 3 ]] || usage
|
|
PID="$3"
|
|
kill -0 "$PID" 2>/dev/null || { echo "native-qa: PID $PID is not running" >&2; exit 2; }
|
|
CLIENTS="$(hyprctl clients -j)"
|
|
GEOMETRY="$(CLIENTS_JSON="$CLIENTS" python - "$PID" <<'PY'
|
|
import json, os, sys
|
|
pid = int(sys.argv[1])
|
|
matches = [c for c in json.loads(os.environ["CLIENTS_JSON"]) if c.get("pid") == pid]
|
|
if len(matches) != 1:
|
|
raise SystemExit(f"expected one Hyprland window for PID {pid}, found {len(matches)}")
|
|
client = matches[0]
|
|
x, y = client["at"]
|
|
w, h = client["size"]
|
|
if w <= 0 or h <= 0:
|
|
raise SystemExit("target window has invalid geometry")
|
|
print(f"{x},{y} {w}x{h}")
|
|
PY
|
|
)"
|
|
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
|
IMAGE="$EVIDENCE_ROOT/$NAME-$STAMP.png"
|
|
grim -g "$GEOMETRY" "$IMAGE"
|
|
HASH="$(sha256sum "$IMAGE" | awk '{print $1}')"
|
|
python - "$STATE_ROOT/native-$NAME-evidence.json" "$PID" "$IMAGE" "$HASH" "$GEOMETRY" <<'PY'
|
|
import json, pathlib, sys, time
|
|
path = pathlib.Path(sys.argv[1])
|
|
path.write_text(json.dumps({
|
|
"pid": int(sys.argv[2]), "image": sys.argv[3], "sha256": sys.argv[4],
|
|
"geometry": sys.argv[5], "captured_at": time.time(),
|
|
}, indent=2) + "\n", encoding="utf-8")
|
|
PY
|
|
python - "$STATE_ROOT/native-$NAME.json" "$IMAGE" "$HASH" "$GEOMETRY" <<'PY'
|
|
import json, pathlib, sys, time
|
|
path = pathlib.Path(sys.argv[1])
|
|
if not path.is_file():
|
|
raise SystemExit("native-qa: missing runner record")
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
data.setdefault("evidence", []).append({
|
|
"image": sys.argv[2], "sha256": sys.argv[3], "geometry": sys.argv[4],
|
|
"captured_at": time.time(),
|
|
})
|
|
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
PY
|
|
echo "PASS native-qa-capture — $NAME — $IMAGE — $HASH"
|
|
;;
|
|
record)
|
|
[[ $# -ge 4 && $# -le 5 ]] || usage
|
|
INDEX="$3"
|
|
RESULT="$4"
|
|
NOTE="${5:-}"
|
|
[[ "$INDEX" =~ ^[1-9][0-9]*$ ]] || { echo "native-qa: assertion index must be positive" >&2; exit 2; }
|
|
[[ "$RESULT" = "pass" || "$RESULT" = "fail" ]] || { echo "native-qa: result must be pass or fail" >&2; exit 2; }
|
|
ASSERTION_COUNT="$(list_section assertions | wc -l)"
|
|
(( INDEX <= ASSERTION_COUNT )) || { echo "native-qa: assertion $INDEX exceeds count $ASSERTION_COUNT" >&2; exit 2; }
|
|
ASSERTION_TEXT="$(list_section assertions | sed -n "${INDEX}p")"
|
|
python - "$STATE_ROOT/native-$NAME.json" "$INDEX" "$RESULT" "$NOTE" "$ASSERTION_TEXT" <<'PY'
|
|
import json, pathlib, sys, time
|
|
path = pathlib.Path(sys.argv[1])
|
|
if not path.is_file():
|
|
raise SystemExit("native-qa: missing runner record")
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
data.setdefault("assertions", {})[sys.argv[2]] = {
|
|
"result": sys.argv[3], "note": sys.argv[4], "text": sys.argv[5],
|
|
"recorded_at": time.time(),
|
|
}
|
|
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
PY
|
|
echo "PASS native-qa-record — $NAME — assertion $INDEX = $RESULT"
|
|
;;
|
|
status)
|
|
[[ $# -eq 2 ]] || usage
|
|
RECORD="$STATE_ROOT/native-$NAME.json"
|
|
[[ -f "$RECORD" ]] || { echo "native-qa: no runner record for $NAME" >&2; exit 2; }
|
|
ASSERTION_COUNT="$(list_section assertions | wc -l)"
|
|
python - "$RECORD" "$ASSERTION_COUNT" <<'PY'
|
|
import json, pathlib, sys, time
|
|
path = pathlib.Path(sys.argv[1])
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
expected = int(sys.argv[2])
|
|
results = data.get("assertions", {})
|
|
passed = sum(item.get("result") == "pass" for item in results.values())
|
|
failed = sum(item.get("result") == "fail" for item in results.values())
|
|
print(f"native-qa-status — assertions {passed} pass, {failed} fail, {expected - len(results)} unrecorded")
|
|
print(f"evidence: {len(data.get('evidence', []))} capture(s)")
|
|
fixture = data.get("fixture")
|
|
if fixture:
|
|
print(f"fixture restored: {bool(fixture.get('restored'))}")
|
|
complete = not failed and len(results) == expected and bool(data.get("evidence"))
|
|
data["scenario_result"] = {"result": "PASS" if complete else "FAIL", "recorded_at": time.time()}
|
|
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
if not complete:
|
|
raise SystemExit(1)
|
|
PY
|
|
;;
|
|
restore)
|
|
[[ $# -eq 3 ]] || usage
|
|
MODE="$3"
|
|
[[ "$MODE" = "--dry-run" || "$MODE" = "--apply" ]] || usage
|
|
RECORD="$STATE_ROOT/native-$NAME.json"
|
|
[[ -f "$RECORD" ]] || { echo "native-qa: no runner record for $NAME" >&2; exit 2; }
|
|
python - "$RECORD" "$MODE" "$ROOT" <<'PY'
|
|
import hashlib, json, os, pathlib, shutil, sys, tempfile, time
|
|
|
|
record = pathlib.Path(sys.argv[1])
|
|
apply = sys.argv[2] == "--apply"
|
|
root = pathlib.Path(sys.argv[3]).resolve()
|
|
data = json.loads(record.read_text(encoding="utf-8"))
|
|
fixture = data.get("fixture")
|
|
if not fixture:
|
|
print("PASS native-qa-restore — scenario has no fixture")
|
|
raise SystemExit(0)
|
|
path = pathlib.Path(fixture["path"]).resolve()
|
|
if path != root and root not in path.parents:
|
|
raise SystemExit(f"native-qa: fixture escapes workspace: {path}")
|
|
pid = data.get("pid")
|
|
if apply and pid:
|
|
try:
|
|
os.kill(int(pid), 0)
|
|
except ProcessLookupError:
|
|
pass
|
|
except PermissionError:
|
|
raise SystemExit(f"native-qa: cannot verify runner PID {pid}; refusing fixture restore")
|
|
else:
|
|
raise SystemExit(f"native-qa: close runner PID {pid} before restoring the fixture")
|
|
current = hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else None
|
|
baseline = fixture.get("baseline_sha256")
|
|
action = "restore snapshot" if fixture.get("existed") else "remove scenario-created fixture"
|
|
print(f"native-qa-restore-plan — {action}")
|
|
print(f"fixture: {path}")
|
|
print(f"baseline sha256: {baseline or '(absent)'}")
|
|
print(f"current sha256: {current or '(absent)'}")
|
|
sys.stdout.flush()
|
|
if not apply:
|
|
raise SystemExit(0)
|
|
if fixture.get("existed"):
|
|
snapshot = pathlib.Path(fixture["snapshot"])
|
|
if not snapshot.is_file():
|
|
raise SystemExit(f"native-qa: missing fixture snapshot: {snapshot}")
|
|
snapshot_hash = hashlib.sha256(snapshot.read_bytes()).hexdigest()
|
|
if snapshot_hash != baseline:
|
|
raise SystemExit("native-qa: fixture snapshot hash does not match recorded baseline")
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with tempfile.NamedTemporaryFile(dir=path.parent, prefix=f".{path.name}.", delete=False) as handle:
|
|
temp = pathlib.Path(handle.name)
|
|
handle.write(snapshot.read_bytes())
|
|
os.replace(temp, path)
|
|
else:
|
|
if path.exists() and not path.is_file():
|
|
raise SystemExit(f"native-qa: refusing to remove non-file fixture path: {path}")
|
|
path.unlink(missing_ok=True)
|
|
fixture["restored"] = True
|
|
fixture["restored_at"] = time.time()
|
|
record.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
print("PASS native-qa-restore")
|
|
PY
|
|
;;
|
|
close)
|
|
[[ $# -eq 3 ]] || usage
|
|
PID="$3"
|
|
RECORD="$STATE_ROOT/native-$NAME.json"
|
|
[[ -f "$RECORD" ]] || { echo "native-qa: no runner record for $NAME" >&2; exit 2; }
|
|
RECORDED="$(python -c 'import json,sys; print(json.load(open(sys.argv[1]))["pid"])' "$RECORD")"
|
|
[[ "$RECORDED" = "$PID" ]] || { echo "native-qa: PID does not match runner record" >&2; exit 2; }
|
|
SPAWNED="$(python -c 'import json,sys; print(str(bool(json.load(open(sys.argv[1])).get("spawned_by_runner"))).lower())' "$RECORD")"
|
|
if [[ "$SPAWNED" = "true" ]] && kill -0 "$PID" 2>/dev/null; then
|
|
CMDLINE="$(tr '\0' ' ' <"/proc/$PID/cmdline")"
|
|
[[ "$CMDLINE" == *"$BINARY_NAME"* ]] || { echo "native-qa: refusing to stop unexpected process: $CMDLINE" >&2; exit 2; }
|
|
kill -TERM "$PID"
|
|
fi
|
|
python - "$RECORD" <<'PY'
|
|
import json, pathlib, time, sys
|
|
path = pathlib.Path(sys.argv[1])
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
data["closed_at"] = time.time()
|
|
data["detached_only"] = not bool(data.get("spawned_by_runner"))
|
|
if data["detached_only"]:
|
|
data["detached_pid"] = data.get("pid")
|
|
data["pid"] = None
|
|
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
PY
|
|
echo "PASS native-qa-close — $NAME — PID $PID"
|
|
;;
|
|
*) usage ;;
|
|
esac
|