mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-11-07 01:53:24 +09:00
- with build-linux reused, retrace-split runs its spawn rows plus OpenRA's inproc rows (the wire evidence); the other 75 inproc rows are replayed by the retrace job's inproc-acceptance rows on the same library - trace_cases.py: github-split-matrix takes --transports and --keep-inproc; the self-test proves the spawn rows unchanged and every dropped inproc row an inproc-acceptance row; only a narrowed matrix that comes out empty raises, so the workflow's named error for an empty full subset still prints - trace-cases: the spawn-only subset is only a saving, so a failed self-test or an empty result is a warning, the output is not written and retrace-split takes the full subset - run_trace_case.cmake: MGTRACE_EXPECT_IPC (a harness name) requires the client's IPC line to carry the listed tokens; retrace-split legs prove strict=0 role-split-state=1 run-ahead=1 and a changed default reds every leg with the rows to restore; the acceptance rows prove strict=1 role-split-state=1 run-ahead=1 and gain the refusal census; canned tests in test_run_trace_case.py
643 lines
30 KiB
Python
643 lines
30 KiB
Python
#!/usr/bin/env python3
|
|
# MobileGL - tools/trace_replay/trace_cases.py
|
|
# Copyright (c) 2025-2026 MobileGL-Dev
|
|
# Licensed under the GNU Lesser General Public License v3.0:
|
|
# https://www.gnu.org/licenses/gpl-3.0.txt
|
|
# https://www.gnu.org/licenses/lgpl-3.0.txt
|
|
# SPDX-License-Identifier: LGPL-3.0-only
|
|
# End of Source File Header
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
TRACE_CASES_JSON = Path(__file__).with_name("trace_cases.json")
|
|
CI_BACKENDS = ("DirectGLES", "DirectVulkan")
|
|
|
|
# Every key a case or the defaults block may carry. An UNKNOWN key is a hard error rather than a
|
|
# silent no-op, which is review finding N-4: `"split": true` mistyped as `"splitt": true` loaded
|
|
# clean, the split subset became [], the GitHub matrix became {"include":[]}, and `retrace-split`
|
|
# was skipped with no red anywhere. Every other way of getting `split` wrong already raised
|
|
# (`"ci": false`, a non-bool value) - the typo was the one hole, and it is the shape that makes a
|
|
# whole CI job quietly stop existing. P6 inverted the key's default (see load_trace_case_manifest),
|
|
# which closes the mirror-image hole: a case added later is IN the split subset unless it says
|
|
# otherwise, so forgetting the key can no longer drop a case out of the lane.
|
|
#
|
|
# Adding a key means adding it here, deliberately, in the same commit. That is the point.
|
|
KNOWN_CASE_KEYS = frozenset({
|
|
"name",
|
|
"trace_archive",
|
|
"trace_file",
|
|
"golden",
|
|
"alternate_golden",
|
|
"target_call",
|
|
"width",
|
|
"height",
|
|
"ssim_threshold",
|
|
"crop_x",
|
|
"crop_y",
|
|
"crop_width",
|
|
"crop_height",
|
|
"coherent_as_flush",
|
|
"timeout_seconds",
|
|
"ci",
|
|
"ci_backends",
|
|
"verify",
|
|
"split",
|
|
"avoid_angle_llvmpipe_explicit_lod_bias",
|
|
"backend_overrides",
|
|
# Documentation only - which trace a derived fixture was made from, and how. Nothing reads it.
|
|
"derived_from",
|
|
})
|
|
|
|
# The only fields a per-backend block may restate. Deliberately three: they are the three the
|
|
# comparison is made of. Letting a backend restate `target_call` or `crop_*` would mean the two
|
|
# backends stopped retracing the same frame of the same trace through the same window, and the
|
|
# rates would no longer be comparable - which is the one thing the two-backend matrix is for.
|
|
BACKEND_OVERRIDE_KEYS = frozenset({"golden", "alternate_golden", "ssim_threshold"})
|
|
|
|
|
|
def load_trace_case_manifest(path=TRACE_CASES_JSON):
|
|
with Path(path).open("r", encoding="utf-8") as file:
|
|
manifest = json.load(file)
|
|
defaults = manifest.get("defaults", {})
|
|
unknown_defaults = sorted(set(defaults) - KNOWN_CASE_KEYS)
|
|
if unknown_defaults:
|
|
raise ValueError(
|
|
f"unknown key(s) in the defaults block: {', '.join(unknown_defaults)}. "
|
|
f"Known keys are {', '.join(sorted(KNOWN_CASE_KEYS))}"
|
|
)
|
|
cases = []
|
|
seen = set()
|
|
for case in manifest.get("cases", []):
|
|
merged = {**defaults, **case}
|
|
name = merged.get("name")
|
|
if not name:
|
|
raise ValueError("trace case is missing name")
|
|
unknown = sorted(set(case) - KNOWN_CASE_KEYS)
|
|
if unknown:
|
|
raise ValueError(
|
|
f"unknown key(s) for {name}: {', '.join(unknown)}. A mistyped flag loads clean and "
|
|
f"turns its whole CI subset into an empty matrix, which GitHub skips with no red. "
|
|
f"Known keys are {', '.join(sorted(KNOWN_CASE_KEYS))}"
|
|
)
|
|
if name in seen:
|
|
raise ValueError(f"duplicate trace case: {name}")
|
|
seen.add(name)
|
|
for key in ("trace_archive", "trace_file", "golden", "target_call", "width", "height"):
|
|
if key not in merged:
|
|
raise ValueError(f"{key} is required for {name}")
|
|
# "verify" opts a case into the MOBILEGL_PIPE_VERIFY retrace subset. It is checked here
|
|
# rather than where the matrix is built so that a typo is a loud manifest error in every
|
|
# consumer (the cmake emitter included) instead of a subset that is quietly one case short.
|
|
verify = merged.get("verify", False)
|
|
if not isinstance(verify, bool):
|
|
raise ValueError(f"verify must be true or false for {name}")
|
|
if verify and not merged.get("ci", True):
|
|
raise ValueError(
|
|
f"{name} is marked verify but excluded from CI, so the verify matrix would drop it"
|
|
)
|
|
# "split" IS AN OPT-OUT, NOT AN OPT-IN, and P6 inverted it deliberately.
|
|
#
|
|
# It began as an opt-in listing one case, because the split arm was one
|
|
# case's worth of work. Now that the whole CI matrix runs split, 40
|
|
# identical `"split": true` lines would carry no information AND would
|
|
# reinstate the exact hole the key was written to close: a case added
|
|
# later gets the monolith arms by default and is silently absent from
|
|
# the split ones. Absent is the one state nothing reds on.
|
|
#
|
|
# So: a CI case is in the split subset unless it says `"split": false`,
|
|
# and saying so is a reviewable claim that this trace cannot cross a
|
|
# role boundary. `ci: false` removes a case from CI entirely and takes
|
|
# its split arms with it - the subset is a SUBSET of the CI matrix, and
|
|
# a case CI never runs cannot be one CI runs split.
|
|
#
|
|
# The arm runs the case's OWN ci_backends, both of them by default. The
|
|
# DirectGLES-only requirement that used to live here is gone with the
|
|
# DirectGLES-only arm (tools/trace_replay/CMakeLists.txt says why).
|
|
split = merged.get("split", merged.get("ci", True))
|
|
if not isinstance(split, bool):
|
|
raise ValueError(f"split must be true or false for {name}")
|
|
if split and not merged.get("ci", True):
|
|
raise ValueError(
|
|
f"{name} is marked split but excluded from CI, so the split matrix would drop it"
|
|
)
|
|
merged["split"] = split
|
|
validate_backend_overrides(merged)
|
|
cases.append(merged)
|
|
return {"defaults": defaults, "cases": cases}
|
|
|
|
|
|
def validate_backend_overrides(case):
|
|
"""`backend_overrides` names the golden/threshold ONE backend uses instead of the shared one.
|
|
|
|
P7 needs it because the device DirectVulkan matrix cannot be scored against the DirectGLES
|
|
picture. Until now the two backends shared `golden` plus an optional `alternate_golden`, and
|
|
`alternate_golden` is an OR, not a per-backend key: a DirectVulkan run that matched the
|
|
DirectGLES golden passed, and a DirectGLES run that matched a Magma-only image passed too.
|
|
That is exactly the "went green on the wrong arm" shape, so a per-backend golden REPLACES the
|
|
shared pair for that backend rather than being added to it (see case_for_backend).
|
|
|
|
Everything here is a load-time refusal rather than a runtime surprise, for the reason the
|
|
KNOWN_CASE_KEYS comment above gives: the failure mode of a manifest key is a lane that
|
|
quietly measures something else.
|
|
"""
|
|
overrides = case.get("backend_overrides")
|
|
if overrides is None:
|
|
return
|
|
name = case["name"]
|
|
if not isinstance(overrides, dict) or not overrides:
|
|
raise ValueError(f"backend_overrides must be a non-empty object for {name}")
|
|
allowed = ci_backends(case)
|
|
for backend, entry in overrides.items():
|
|
if backend not in CI_BACKENDS:
|
|
raise ValueError(f"unknown backend in backend_overrides for {name}: {backend}")
|
|
if backend not in allowed:
|
|
# An override for a backend the case never runs can only ever be dead text, and a
|
|
# dead override reads exactly like a live one to whoever audits the golden next.
|
|
raise ValueError(
|
|
f"backend_overrides names {backend} but {name} does not run it "
|
|
f"(ci_backends = {', '.join(allowed)})")
|
|
if not isinstance(entry, dict) or not entry:
|
|
raise ValueError(f"backend_overrides[{backend}] must be a non-empty object for {name}")
|
|
unknown = sorted(set(entry) - BACKEND_OVERRIDE_KEYS)
|
|
if unknown:
|
|
raise ValueError(
|
|
f"backend_overrides[{backend}] for {name} may only restate "
|
|
f"{', '.join(sorted(BACKEND_OVERRIDE_KEYS))}; got {', '.join(unknown)}")
|
|
for key in ("golden", "alternate_golden"):
|
|
if key in entry and not (isinstance(entry[key], str) and entry[key]):
|
|
raise ValueError(f"backend_overrides[{backend}].{key} must be a non-empty "
|
|
f"path for {name}")
|
|
threshold = entry.get("ssim_threshold")
|
|
if "ssim_threshold" in entry and (isinstance(threshold, bool)
|
|
or not isinstance(threshold, (int, float))):
|
|
raise ValueError(f"backend_overrides[{backend}].ssim_threshold must be a number "
|
|
f"for {name}")
|
|
if "alternate_golden" in entry and "golden" not in entry:
|
|
# Otherwise the block would add a second acceptable image to the SHARED golden,
|
|
# which is the OR this key exists to remove.
|
|
raise ValueError(f"backend_overrides[{backend}] for {name} sets alternate_golden "
|
|
f"without golden; state the backend's own golden explicitly")
|
|
|
|
|
|
def case_for_backend(case, backend):
|
|
"""The case as ONE backend sees it: its own golden pair and threshold, or the shared ones.
|
|
|
|
Returns the case UNCHANGED - the same object - when it declares no `backend_overrides` at
|
|
all, so a manifest without the key produces byte-identical selections, environments and run
|
|
fingerprints to the ones it produced before the key existed.
|
|
|
|
THE KEY ITSELF IS DROPPED from the result even for a backend that overrides nothing, and
|
|
that is deliberate: `run_tcp_matrix` fingerprints the resolved case, so leaving the block in
|
|
would make "give DirectVulkan its own golden" invalidate every DirectGLES checkpoint in the
|
|
same run - a full re-sweep of an arm whose golden, threshold and trace did not move.
|
|
"""
|
|
if "backend_overrides" not in case:
|
|
return case
|
|
merged = {key: value for key, value in case.items() if key != "backend_overrides"}
|
|
overrides = (case.get("backend_overrides") or {}).get(backend)
|
|
if overrides:
|
|
if "golden" in overrides:
|
|
# REPLACE, do not extend: an override's golden is this backend's whole answer.
|
|
merged["alternate_golden"] = ""
|
|
merged.update(overrides)
|
|
return merged
|
|
|
|
|
|
def backend_override_goldens(case, backend):
|
|
"""The goldens this case declares for OTHER backends - what `backend` must never match.
|
|
|
|
Two sources, and the second is the one that is easy to miss: when THIS backend replaced the
|
|
shared golden, the shared `golden`/`alternate_golden` stop being neutral and become the
|
|
other backends' images. A DirectVulkan run that matched the shared (DirectGLES) picture is
|
|
precisely the accident the per-backend key exists to make impossible, so it is named here
|
|
rather than reported as "a different golden".
|
|
"""
|
|
overrides = case.get("backend_overrides") or {}
|
|
foreign = set()
|
|
if "golden" in (overrides.get(backend) or {}):
|
|
for key in ("golden", "alternate_golden"):
|
|
if case.get(key):
|
|
foreign.add(case[key])
|
|
for other, entry in overrides.items():
|
|
if other == backend:
|
|
continue
|
|
for key in ("golden", "alternate_golden"):
|
|
if entry.get(key):
|
|
foreign.add(entry[key])
|
|
resolved = case_for_backend(case, backend)
|
|
for key in ("golden", "alternate_golden"):
|
|
foreign.discard(resolved.get(key))
|
|
return foreign
|
|
|
|
|
|
def refuse_backend_overrides(cases, consumer):
|
|
"""Consumers that cannot express a per-backend golden must say so, not drop it silently.
|
|
|
|
`add_trace_replay_test_for_backends` and the APK matrix both carry ONE golden path for both
|
|
backends. Emitting a case that declares a per-backend golden through either of them would
|
|
register the DirectVulkan arm against the DirectGLES image - the precise failure this key was
|
|
added to close - so they refuse until they learn the key.
|
|
"""
|
|
named = [case["name"] for case in cases if case.get("backend_overrides")]
|
|
if named:
|
|
raise ValueError(
|
|
f"{consumer} cannot express backend_overrides yet, and the following case(s) declare "
|
|
f"it: {', '.join(named)}. Teach {consumer} the key in the same commit that adds the "
|
|
f"override, or the arm it emits runs against another backend's golden.")
|
|
|
|
|
|
def load_trace_cases(path=TRACE_CASES_JSON):
|
|
return load_trace_case_manifest(path)["cases"]
|
|
|
|
|
|
def case_with_defaults(case):
|
|
return dict(case)
|
|
|
|
|
|
def trace_case_names(path=TRACE_CASES_JSON):
|
|
return [case["name"] for case in load_trace_cases(path)]
|
|
|
|
|
|
def find_trace_case(name, path=TRACE_CASES_JSON):
|
|
for case in load_trace_cases(path):
|
|
if case["name"] == name:
|
|
return case
|
|
raise KeyError(name)
|
|
|
|
|
|
def fixture_files(case, fixture_root):
|
|
root = fixture_root.rstrip("/\\")
|
|
files = [
|
|
f"{root}/{case['trace_archive']}",
|
|
f"{root}/{case['golden']}",
|
|
]
|
|
if case.get("alternate_golden"):
|
|
files.append(f"{root}/{case['alternate_golden']}")
|
|
return files
|
|
|
|
|
|
def github_apk_case(case):
|
|
refuse_backend_overrides([case], "the APK retrace matrix (github_apk_case)")
|
|
result = dict(case)
|
|
for key in ("trace_archive", "golden", "alternate_golden"):
|
|
if result.get(key):
|
|
result[key] = f"tools/trace_replay/fixtures/{result[key]}"
|
|
return result
|
|
|
|
|
|
def ci_trace_cases(cases):
|
|
return [case for case in cases if case.get("ci", True)]
|
|
|
|
|
|
def verify_trace_cases(cases):
|
|
"""The subset the third CI mode retraces.
|
|
|
|
The verify build compares two state models at every verb boundary and again at every accessor
|
|
read, which the design budgets at 5-10x, so the per-push lane runs a named subset and the full
|
|
79-case sweep happens at the phase exit and on workflow_dispatch.
|
|
"""
|
|
return [case for case in cases if case.get("verify", False)]
|
|
|
|
|
|
def split_trace_cases(cases):
|
|
"""The subset the split CI modes retrace - inproc AND spawn, both backends.
|
|
|
|
P5's phase gate named exactly one case, OpenRA at SSIM >= 0.99, because one case was the
|
|
work. P6 runs the whole CI matrix across a process boundary, so the subset is now every CI
|
|
case that has not explicitly opted out. load_cases() has already resolved the default, so
|
|
"split" is present and boolean on every case by the time this reads it.
|
|
"""
|
|
return [case for case in cases if case.get("split", False)]
|
|
|
|
|
|
def ci_backends(case):
|
|
backends = case.get("ci_backends")
|
|
if backends is None:
|
|
return CI_BACKENDS
|
|
if not isinstance(backends, list) or not backends:
|
|
raise ValueError(f"ci_backends must be a non-empty list for {case['name']}")
|
|
unknown = [backend for backend in backends if backend not in CI_BACKENDS]
|
|
if unknown:
|
|
raise ValueError(
|
|
f"unknown ci_backends for {case['name']}: {', '.join(unknown)}"
|
|
)
|
|
if len(set(backends)) != len(backends):
|
|
raise ValueError(f"ci_backends contains duplicates for {case['name']}")
|
|
return backends
|
|
|
|
|
|
def github_test_matrix(cases):
|
|
return {
|
|
"include": [
|
|
{"backend": backend, "case": case["name"]}
|
|
for case in cases
|
|
for backend in ci_backends(case)
|
|
]
|
|
}
|
|
|
|
|
|
def github_verify_matrix(cases):
|
|
return github_test_matrix(verify_trace_cases(cases))
|
|
|
|
|
|
# The two transports the split retrace lane runs. inproc is the CONTROL - same library, same
|
|
# codec, same picture, server role on a thread HERE - and spawn is the claim. Both, always: a
|
|
# spawn failure that inproc also shows is a phase bug, and one inproc does not show is a
|
|
# transport bug, and collapsing the two makes that distinction unaskable.
|
|
SPLIT_TRANSPORTS = ("inproc", "spawn")
|
|
|
|
|
|
def github_split_matrix(cases, transports=SPLIT_TRANSPORTS, keep_inproc=()):
|
|
"""{backend, case, transport} for the split arm.
|
|
|
|
THREE DIMENSIONS, NOT TWO. The job used to take {backend, case} and hard-code
|
|
MOBILEGL_TRANSPORT=inproc in its own run block, so `spawn` had no way into CI at all. The
|
|
backend list is the case's own ci_backends - the same list the monolith matrix uses - so a
|
|
case restricted to one backend stays restricted here instead of being registered with a
|
|
backend it does not run.
|
|
|
|
`transports` narrows the arms (test.yml drops inproc when the inproc-acceptance retrace rows
|
|
already replay the same library); `keep_inproc` names split cases whose inproc rows stay
|
|
anyway - OpenRA's, which carry the wire-volume evidence. A narrowed matrix that comes out
|
|
empty is an error; the full one is returned empty, and test.yml names that error itself.
|
|
"""
|
|
unknown = [t for t in transports if t not in SPLIT_TRANSPORTS]
|
|
if unknown or not transports:
|
|
raise ValueError(f"split transports must be a non-empty subset of {SPLIT_TRANSPORTS}: {transports}")
|
|
split_cases = split_trace_cases(cases)
|
|
split_names = {case["name"] for case in split_cases}
|
|
missing = [name for name in keep_inproc if name not in split_names]
|
|
if missing:
|
|
raise ValueError(f"--keep-inproc names cases that are not split CI cases: {missing}")
|
|
rows = [
|
|
{"backend": backend, "case": case["name"], "transport": transport}
|
|
for case in split_cases
|
|
for backend in ci_backends(case)
|
|
for transport in SPLIT_TRANSPORTS
|
|
if transport in transports or (transport == "inproc" and case["name"] in keep_inproc)
|
|
]
|
|
narrowed = tuple(transports) != SPLIT_TRANSPORTS or bool(keep_inproc)
|
|
if not rows and narrowed:
|
|
raise ValueError("the narrowed split matrix is empty")
|
|
return {"include": rows}
|
|
|
|
|
|
def github_apk_matrix(cases):
|
|
backends = {
|
|
"DirectGLES": {"name": "DirectGLES", "gpu": "software"},
|
|
"DirectVulkan": {"name": "DirectVulkan", "gpu": "lavapipe"},
|
|
}
|
|
return {
|
|
"include": [
|
|
{"backend": backends[backend], "case": github_apk_case(case)}
|
|
for case in cases
|
|
for backend in ci_backends(case)
|
|
]
|
|
}
|
|
|
|
|
|
def github_transport_matrix(cases, *, apk=False):
|
|
"""Run the entire original matrix as control and as acceptance, once per transport.
|
|
|
|
The transports use the same D/P artifact and each case's existing backend
|
|
list. The legacy split:true subset does not limit this main acceptance matrix.
|
|
Nested APK case/backend metadata is deliberately left untouched.
|
|
|
|
THE SPAWN LANE IS APK-ONLY, and that is a statement about where the evidence is missing
|
|
rather than about what spawn can do. On Linux the spawn arm already has a job of its own -
|
|
test.yml's `retrace-split`, which is {backend, case, transport} over spawn (and inproc too
|
|
when it cannot reuse this matrix's inproc rows' library) and whose legs prove in-test that
|
|
the transport resolved and, on OpenRA, that draw records crossed the wire
|
|
(run_trace_case.cmake's evidence). Adding spawn to THIS matrix would pay
|
|
for a second full Linux sweep that proves nothing the first one does not, and test.yml's
|
|
`retrace` job has neither the server image staged nor MOBILEGL_IPC_SERVER_PATH set - so the
|
|
lane would not merely be redundant, it would be red.
|
|
|
|
On Android there is no such job. Until this lane existed, nothing in CI ever started a
|
|
second MobileGL process on a device.
|
|
"""
|
|
build = github_apk_matrix if apk else github_test_matrix
|
|
if not cases:
|
|
raise ValueError("transport matrix needs at least one trace case")
|
|
lanes = [
|
|
(cases, "monolith-control", "monolith"),
|
|
(cases, "inproc-acceptance", "inproc"),
|
|
]
|
|
if apk:
|
|
lanes.append((cases, "spawn-acceptance", "spawn"))
|
|
return {"include": [
|
|
{**row, "lane": lane, "transport": transport}
|
|
for selected, lane, transport in lanes
|
|
for row in build(selected)["include"]
|
|
]}
|
|
|
|
|
|
def self_test_transport_matrices():
|
|
cases = ci_trace_cases(load_trace_cases())
|
|
for apk in (False, True):
|
|
build = github_apk_matrix if apk else github_test_matrix
|
|
original = build(cases)["include"]
|
|
mixed = github_transport_matrix(cases, apk=apk)["include"]
|
|
controls = [row for row in mixed if row["transport"] == "monolith"]
|
|
acceptance = [row for row in mixed if row["transport"] == "inproc"]
|
|
strip_lane = lambda rows: [{key: value for key, value in row.items()
|
|
if key not in ("lane", "transport")} for row in rows]
|
|
spawned = [row for row in mixed if row["transport"] == "spawn"]
|
|
assert strip_lane(controls) == original, "control lost original case/backend/parameters"
|
|
assert strip_lane(acceptance) == original, "acceptance lost original case/backend/parameters"
|
|
assert all(row["lane"] == "monolith-control" for row in controls)
|
|
assert all(row["lane"] == "inproc-acceptance" for row in acceptance)
|
|
# THE SPAWN LANE IS APK-ONLY AND MUST NOT DRIFT INTO THE LINUX MATRIX. test.yml's
|
|
# `retrace` job exports matrix.transport straight into MOBILEGL_TRANSPORT and stages no
|
|
# server image, so a spawn row reaching it would be red for a packaging reason and say
|
|
# nothing about the transport. Asserted in both directions: present for apk, absent
|
|
# otherwise - an accidental `if apk` removal has to be caught here rather than in CI.
|
|
if apk:
|
|
assert strip_lane(spawned) == original, "spawn lane lost original case/backend/parameters"
|
|
assert all(row["lane"] == "spawn-acceptance" for row in spawned)
|
|
else:
|
|
assert not spawned, "the Linux transport matrix must carry no spawn lane"
|
|
identities = [(row["case"]["name"] if apk else row["case"],
|
|
row["backend"]["name"] if apk else row["backend"], row["transport"])
|
|
for row in mixed]
|
|
assert len(identities) == len(set(identities)), "duplicate lane identity"
|
|
# A restricted case keeps exactly its original backend and golden data.
|
|
restricted = [{**cases[0], "split": False, "ci_backends": ["DirectGLES"]}]
|
|
rows = github_transport_matrix(restricted, apk=apk)["include"]
|
|
expected_lanes = 3 if apk else 2
|
|
assert len(rows) == expected_lanes
|
|
assert all(strip_lane(rows[:1]) == strip_lane(rows[index:index + 1])
|
|
for index in range(1, expected_lanes))
|
|
try:
|
|
github_transport_matrix([], apk=apk)
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
raise AssertionError("an empty acceptance lane silently passed")
|
|
print(f"transport matrix {'APK' if apk else 'Linux'}: {len(controls)} unchanged controls, "
|
|
f"{len(acceptance)} inproc acceptance entries, {len(spawned)} spawn acceptance "
|
|
f"entries; exact metadata and negative controls OK")
|
|
|
|
|
|
def self_test_split_matrix(cases=None):
|
|
"""retrace-split may drop its inproc rows only because the inproc-acceptance rows replay them."""
|
|
if cases is None:
|
|
cases = ci_trace_cases(load_trace_cases())
|
|
full = github_split_matrix(cases)["include"]
|
|
spawn_only = github_split_matrix(cases, transports=("spawn",), keep_inproc=("OpenRA",))["include"]
|
|
assert [r for r in spawn_only if r["transport"] == "spawn"] == [r for r in full if r["transport"] == "spawn"], \
|
|
"the spawn-only matrix lost or changed a spawn row"
|
|
assert any(r["transport"] == "spawn" for r in spawn_only), "the spawn-only matrix has no spawn row"
|
|
kept = [r for r in spawn_only if r["transport"] == "inproc"]
|
|
assert kept and all(r["case"] == "OpenRA" for r in kept), "the kept inproc rows are not OpenRA's"
|
|
assert all(r in full for r in kept), "a kept inproc row is not a row of the full matrix"
|
|
acceptance = {(r["backend"], r["case"]) for r in github_transport_matrix(cases)["include"]
|
|
if r["lane"] == "inproc-acceptance"}
|
|
dropped = [(r["backend"], r["case"]) for r in full if r["transport"] == "inproc" and r not in kept]
|
|
uncovered = [row for row in dropped if row not in acceptance]
|
|
assert not uncovered, f"inproc rows dropped from retrace-split that no acceptance row replays: {uncovered}"
|
|
for bad in ({"transports": ("unix",)}, {"transports": ()}, {"keep_inproc": ("no-such-case",)}):
|
|
try:
|
|
github_split_matrix(cases, **bad)
|
|
except ValueError:
|
|
continue
|
|
raise AssertionError(f"github_split_matrix accepted {bad}")
|
|
try:
|
|
github_split_matrix([], transports=("spawn",))
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
raise AssertionError("an empty narrowed split matrix silently passed")
|
|
assert github_split_matrix([]) == {"include": []}, \
|
|
"the full split matrix must come back empty, for the workflow's named error"
|
|
print(f"split matrix: {len(full)} rows, {len(spawn_only)} with inproc dropped ({len(kept)} OpenRA inproc "
|
|
f"kept); all {len(dropped)} dropped inproc rows are inproc-acceptance rows")
|
|
|
|
|
|
def cmake_quote(value):
|
|
return '"' + str(value).replace("\\", "/").replace('"', '\\"') + '"'
|
|
|
|
|
|
def emit_cmake(cases, fixture_root):
|
|
refuse_backend_overrides(cases, "the CTest catalog emitter (emit_cmake)")
|
|
root = fixture_root.rstrip("/\\")
|
|
lines = ["# Generated from tools/trace_replay/trace_cases.json.", ""]
|
|
keys = [
|
|
("TRACE_ARCHIVE", "trace_archive", True),
|
|
("TRACE_FILE", "trace_file", False),
|
|
("GOLDEN", "golden", True),
|
|
("ALTERNATE_GOLDEN", "alternate_golden", True),
|
|
("TARGET_CALL", "target_call", False),
|
|
("WIDTH", "width", False),
|
|
("HEIGHT", "height", False),
|
|
("SSIM_THRESHOLD", "ssim_threshold", False),
|
|
("CROP_X", "crop_x", False),
|
|
("CROP_Y", "crop_y", False),
|
|
("CROP_WIDTH", "crop_width", False),
|
|
("CROP_HEIGHT", "crop_height", False),
|
|
("COHERENT_AS_FLUSH", "coherent_as_flush", False),
|
|
]
|
|
def emit_one(function, case):
|
|
lines.append(f"{function}({cmake_quote(case['name'])}")
|
|
for cmake_key, json_key, fixture_path in keys:
|
|
value = case.get(json_key)
|
|
if value is None or value == "":
|
|
continue
|
|
if fixture_path:
|
|
value = f"{root}/{value}"
|
|
lines.append(f" {cmake_key} {cmake_quote(value)}")
|
|
lines.append(")")
|
|
lines.append("")
|
|
|
|
for case in cases:
|
|
emit_one("add_trace_replay_test_for_backends", case)
|
|
# P5's split arm, emitted beside the monolith pair rather than in a block of its own so
|
|
# that a case and its variant always carry identical parameters. The CMake side is a
|
|
# no-op unless MOBILEGL_BUILD_DISAGGREGATED is ON.
|
|
if case.get("split", False):
|
|
emit_one("add_trace_replay_split_test", case)
|
|
return "\n".join(lines)
|
|
|
|
|
|
def parse_args():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--case", dest="case_name", help="Trace case name.")
|
|
parser.add_argument("--ci", action="store_true", help="Only include cases enabled for CI.")
|
|
parser.add_argument("--self-test-transport-matrices", action="store_true")
|
|
parser.add_argument("--self-test-split-matrix", action="store_true",
|
|
help="prove the spawn-only split matrix drops only inproc-acceptance rows")
|
|
parser.add_argument("--fixture-root", default="tools/trace_replay/fixtures")
|
|
parser.add_argument("--transports", default=",".join(SPLIT_TRANSPORTS),
|
|
help="github-split-matrix: the comma-separated split transports to emit")
|
|
parser.add_argument("--keep-inproc", default="",
|
|
help="github-split-matrix: comma-separated split cases whose inproc rows stay")
|
|
parser.add_argument(
|
|
"--format",
|
|
choices=(
|
|
"names",
|
|
"github-test-matrix",
|
|
"github-test-transport-matrix",
|
|
"github-verify-matrix",
|
|
"github-split-matrix",
|
|
"github-apk",
|
|
"github-apk-matrix",
|
|
"github-apk-transport-matrix",
|
|
"fixture-files",
|
|
"cmake",
|
|
),
|
|
default="names",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def main():
|
|
args = parse_args()
|
|
if args.self_test_transport_matrices:
|
|
self_test_transport_matrices()
|
|
return 0
|
|
if args.self_test_split_matrix:
|
|
self_test_split_matrix()
|
|
return 0
|
|
cases = load_trace_cases()
|
|
if args.ci:
|
|
cases = ci_trace_cases(cases)
|
|
if args.format == "names":
|
|
print(json.dumps([case["name"] for case in cases], separators=(",", ":")))
|
|
elif args.format == "github-test-matrix":
|
|
print(json.dumps(github_test_matrix(cases), separators=(",", ":")))
|
|
elif args.format == "github-test-transport-matrix":
|
|
print(json.dumps(github_transport_matrix(cases), separators=(",", ":")))
|
|
elif args.format == "github-verify-matrix":
|
|
print(json.dumps(github_verify_matrix(cases), separators=(",", ":")))
|
|
elif args.format == "github-split-matrix":
|
|
transports = tuple(t for t in args.transports.split(",") if t)
|
|
keep = tuple(c for c in args.keep_inproc.split(",") if c)
|
|
print(json.dumps(github_split_matrix(cases, transports, keep), separators=(",", ":")))
|
|
elif args.format == "github-apk":
|
|
print(json.dumps([github_apk_case(case) for case in cases], separators=(",", ":")))
|
|
elif args.format == "github-apk-matrix":
|
|
print(json.dumps(github_apk_matrix(cases), separators=(",", ":")))
|
|
elif args.format == "github-apk-transport-matrix":
|
|
print(json.dumps(github_transport_matrix(cases, apk=True), separators=(",", ":")))
|
|
elif args.format == "fixture-files":
|
|
if not args.case_name:
|
|
print("--case is required for --format fixture-files", file=sys.stderr)
|
|
return 2
|
|
try:
|
|
case = find_trace_case(args.case_name)
|
|
except KeyError:
|
|
print(f"unknown trace case: {args.case_name}", file=sys.stderr)
|
|
return 2
|
|
print("\n".join(fixture_files(case, args.fixture_root)))
|
|
elif args.format == "cmake":
|
|
print(emit_cmake(cases, args.fixture_root))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|