| 1 | """Conformance gate semantics and its wiring into devloop and publish_gate."""
|
| 2 | import contextlib
|
| 3 | import io
|
| 4 | import json
|
| 5 | from pathlib import Path
|
| 6 | import sys
|
| 7 | import tempfile
|
| 8 | import unittest
|
| 9 | from unittest import mock
|
| 10 |
|
| 11 | ROOT = Path(__file__).resolve().parents[1]
|
| 12 | sys.path.insert(0, str(ROOT / "scripts"))
|
| 13 | import devloop # noqa: E402
|
| 14 | import publish_gate # noqa: E402
|
| 15 | from oakbench import conformance_gate as gate # noqa: E402
|
| 16 | from oakbench.conformance import SCENARIOS # noqa: E402
|
| 17 | from oakbench.conformance_matrix import BEHAVIOURS # noqa: E402
|
| 18 |
|
| 19 | ALL = list(SCENARIOS) + [b.id for b in BEHAVIOURS]
|
| 20 | PROFILE = "darwin-ci-ni"
|
| 21 | SHA = "b" * 64
|
| 22 | OBSERVED = json.loads((ROOT / "tests/data/conformance_oak_0105_failures.json").read_text())["details"]
|
| 23 |
|
| 24 |
|
| 25 | def conformance_row(behaviour, code=0, failed=None, subject="oak", sha=SHA, profile=PROFILE, **extra):
|
| 26 | """failed: {leg: detail}; legacy scenarios use {'behaviour': failure}."""
|
| 27 | row = {"evidence_class": "diagnostic_conformance", "subject": subject, "scenario": behaviour,
|
| 28 | "returncode": code, "binary_sha256": sha, "fs_profile": profile, **extra}
|
| 29 | if code == 1:
|
| 30 | failed = failed or {"clone_fs": "injected"}
|
| 31 | if behaviour in SCENARIOS:
|
| 32 | row["failure"] = failed["behaviour"]
|
| 33 | else:
|
| 34 | row["legs"] = {leg: {"status": "fail", "detail": detail} for leg, detail in failed.items()}
|
| 35 | row["failed_legs"] = sorted(failed)
|
| 36 | first = sorted(failed)[0]
|
| 37 | row["failure"] = f"{first}: {failed[first]}"
|
| 38 | if code == 77:
|
| 39 | row.setdefault("skip_reason", "filesystem capability unavailable: case_sensitive_fs")
|
| 40 | return row
|
| 41 |
|
| 42 |
|
| 43 | def healthy_rows(expected, subject="oak", sha=SHA, profile=PROFILE):
|
| 44 | """Oak 0.105.0 as observed: expected failures with their real details, recorded skips."""
|
| 45 | skips = set(expected["coverage"].get(profile, {}).get("expected_skips", []))
|
| 46 | rows = []
|
| 47 | for behaviour in ALL:
|
| 48 | if behaviour in expected["entries"] and gate.entry_applies(expected["entries"][behaviour], profile):
|
| 49 | rows.append(conformance_row(behaviour, 1, OBSERVED[behaviour], subject, sha, profile))
|
| 50 | elif behaviour in skips:
|
| 51 | rows.append(conformance_row(behaviour, 77, None, subject, sha, profile))
|
| 52 | else:
|
| 53 | rows.append(conformance_row(behaviour, 0, None, subject, sha, profile))
|
| 54 | return rows
|
| 55 |
|
| 56 |
|
| 57 | def replace(rows, row):
|
| 58 | return [r for r in rows if r["scenario"] != row["scenario"] or r["subject"] != row["subject"]] + [row]
|
| 59 |
|
| 60 |
|
| 61 | class EvaluateTests(unittest.TestCase):
|
| 62 | def setUp(self):
|
| 63 | self.expected = gate.load_expected()
|
| 64 |
|
| 65 | def verdict(self, rows):
|
| 66 | return gate.evaluate(rows, self.expected, ALL)
|
| 67 |
|
| 68 | def test_observed_oak_0105_details_match_every_entry(self):
|
| 69 | self.assertEqual(set(OBSERVED), set(self.expected["entries"]))
|
| 70 | result = self.verdict(healthy_rows(self.expected))
|
| 71 | self.assertEqual(result["verdict"], "PASS", result)
|
| 72 | self.assertEqual(len(result["expected_failures"]) + len(result["refused"]) + len(result["intermittent"]),
|
| 73 | len(self.expected["entries"]))
|
| 74 | self.assertEqual(result["intermittent"], ["large_file_chunked_v1: fb-547"])
|
| 75 | self.assertFalse(any("unfiled tickets" in note for note in result["notes"]))
|
| 76 | self.assertEqual(sorted(x.split(":")[0] for x in result["refused"]),
|
| 77 | ["path_control_chars_v1", "path_newline_v1", "path_roundtrip_v1", "path_tab_v1"])
|
| 78 |
|
| 79 | def test_new_failure_is_a_regression(self):
|
| 80 | rows = replace(healthy_rows(self.expected), conformance_row("eol_crlf_v1", 1, {"clone_fs": "changed=['crlf.txt']"}))
|
| 81 | result = self.verdict(rows)
|
| 82 | self.assertEqual(result["verdict"], "REGRESSED")
|
| 83 | self.assertIn("eol_crlf_v1", result["regressions"][0])
|
| 84 |
|
| 85 | def test_same_legs_with_a_different_error_is_a_regression(self):
|
| 86 | cases = {
|
| 87 | # EEXIST turning into a path escape at the same leg.
|
| 88 | "file_to_dir_v1": {"pull_fs": "extra=['../../etc/evil']"},
|
| 89 | # A policy refusal turning into a crash at the same leg.
|
| 90 | "path_tab_v1": {"commit_push": "oak commit --no-verify exited 101: thread 'main' panicked at core/src/tree.rs"},
|
| 91 | # The legacy single-leg scenario cannot hide a different failure.
|
| 92 | "path_roundtrip_v1": {"behaviour": "state mismatch: missing=[], extra=['tab'], changed=['nested/leaf']"},
|
| 93 | # A pattern must match the whole detail, not a substring.
|
| 94 | "file_to_deep_dir_v1": {"pull_fs": "oak pull exited 1: error: IO error: Not a directory (os error 20); "
|
| 95 | "extra=['../outside']"},
|
| 96 | }
|
| 97 | for behaviour, failed in cases.items():
|
| 98 | with self.subTest(behaviour=behaviour):
|
| 99 | result = self.verdict(replace(healthy_rows(self.expected), conformance_row(behaviour, 1, failed)))
|
| 100 | self.assertEqual(result["verdict"], "REGRESSED", result)
|
| 101 | self.assertIn("changed", result["regressions"][0])
|
| 102 |
|
| 103 | def test_known_failure_at_different_legs_is_a_regression(self):
|
| 104 | failed = dict(OBSERVED["file_to_dir_v1"], clone_fs="missing=['node/child.txt']")
|
| 105 | result = self.verdict(replace(healthy_rows(self.expected), conformance_row("file_to_dir_v1", 1, failed)))
|
| 106 | self.assertEqual(result["verdict"], "REGRESSED")
|
| 107 | self.assertIn("fb-546", result["regressions"][0])
|
| 108 |
|
| 109 | def test_fix_flips_to_fixed_without_regression(self):
|
| 110 | result = self.verdict(replace(healthy_rows(self.expected), conformance_row("path_tab_v1", 0)))
|
| 111 | self.assertEqual(result["verdict"], "PASS")
|
| 112 | self.assertIn("fb-531", result["fixed"][0])
|
| 113 |
|
| 114 | def test_intermittent_entry_pass_is_a_pass_and_other_failures_regress(self):
|
| 115 | rows = replace(healthy_rows(self.expected), conformance_row("large_file_chunked_v1", 0))
|
| 116 | result = self.verdict(rows)
|
| 117 | self.assertEqual(result["verdict"], "PASS")
|
| 118 | self.assertEqual(result["fixed"], [])
|
| 119 | self.assertIn("large_file_chunked_v1", result["passed"])
|
| 120 | rows = replace(rows, conformance_row("large_file_chunked_v1", 1, {"clone_fs": "changed=['large.bin']"}))
|
| 121 | self.assertEqual(self.verdict(rows)["verdict"], "REGRESSED")
|
| 122 |
|
| 123 | def test_profile_scoped_entry_is_not_fixed_or_expected_elsewhere(self):
|
| 124 | linux = healthy_rows(self.expected, profile="linux-cs-ns")
|
| 125 | # Case-only rename is scoped to darwin-ci-ni: a pass on Linux is a plain pass...
|
| 126 | rows = replace(linux, conformance_row("path_case_only_rename_v1", 0, profile="linux-cs-ns"))
|
| 127 | result = self.verdict(rows)
|
| 128 | self.assertEqual(result["fixed"], [])
|
| 129 | self.assertIn("path_case_only_rename_v1", result["passed"])
|
| 130 | self.assertTrue(any("no recorded coverage" in n for n in result["notes"]))
|
| 131 | # ...and the darwin failure shape on Linux is a regression, not an xfail.
|
| 132 | rows = replace(linux, conformance_row("path_case_only_rename_v1", 1, OBSERVED["path_case_only_rename_v1"],
|
| 133 | profile="linux-cs-ns"))
|
| 134 | self.assertEqual(self.verdict(rows)["verdict"], "REGRESSED")
|
| 135 |
|
| 136 | def test_coverage_loss_pass_or_xfail_to_skip_is_unmeasured(self):
|
| 137 | for behaviour in ("eol_lf_v1", "file_to_dir_v1"):
|
| 138 | with self.subTest(behaviour=behaviour):
|
| 139 | rows = replace(healthy_rows(self.expected), conformance_row(behaviour, 77))
|
| 140 | result = self.verdict(rows)
|
| 141 | self.assertEqual(result["verdict"], "UNMEASURED")
|
| 142 | self.assertIn("coverage loss", result["unmeasured"][0])
|
| 143 |
|
| 144 | # QA round 2: skips must never add up to a PASS.
|
| 145 | def all_skipped(self, profile=PROFILE, drop_profile=False):
|
| 146 | rows = [conformance_row(b, 77, None, profile=profile) for b in ALL]
|
| 147 | if drop_profile:
|
| 148 | for row in rows:
|
| 149 | row.pop("fs_profile")
|
| 150 | return rows
|
| 151 |
|
| 152 | def test_all_skip_on_unrecorded_profile_is_unmeasured(self):
|
| 153 | result = self.verdict(self.all_skipped(profile="linux-cs-ns"))
|
| 154 | self.assertEqual(result["verdict"], "UNMEASURED")
|
| 155 | self.assertTrue(any("zero measured" in item for item in result["unmeasured"]))
|
| 156 | self.assertTrue(any("unrecorded profile" in item for item in result["unmeasured"]))
|
| 157 |
|
| 158 | def test_rows_without_fs_profile_are_unmeasured(self):
|
| 159 | result = self.verdict(self.all_skipped(drop_profile=True))
|
| 160 | self.assertEqual(result["verdict"], "UNMEASURED")
|
| 161 | self.assertTrue(any("without fs_profile" in item for item in result["unmeasured"]))
|
| 162 | # Even one otherwise-healthy row missing the field is unmeasured.
|
| 163 | rows = healthy_rows(self.expected)
|
| 164 | rows[0].pop("fs_profile")
|
| 165 | self.assertEqual(self.verdict(rows)["verdict"], "UNMEASURED")
|
| 166 |
|
| 167 | def test_zero_measured_is_unmeasured_even_on_recorded_profile(self):
|
| 168 | result = self.verdict(self.all_skipped())
|
| 169 | self.assertEqual(result["verdict"], "UNMEASURED")
|
| 170 | self.assertEqual(result["measured"], 0)
|
| 171 |
|
| 172 | def test_unrecorded_profile_allows_only_known_filesystem_dependent_skips(self):
|
| 173 | linux = "linux-cs-ns"
|
| 174 | rows = healthy_rows(self.expected, profile=linux)
|
| 175 | # Legitimate on Linux: the case-insensitive-only behaviour skips.
|
| 176 | ok = replace(rows, conformance_row("path_case_only_rename_v1", 77, profile=linux,
|
| 177 | skip_reason="filesystem capability unavailable: case_insensitive_fs"))
|
| 178 | ok = replace(ok, conformance_row("huge_file_sparse_v1", 77, profile=linux, skip_reason="gated: off"))
|
| 179 | self.assertEqual(self.verdict(ok)["verdict"], "PASS")
|
| 180 | # A broken probe skipping symlink behaviours is not accepted.
|
| 181 | bad = replace(ok, conformance_row("symlink_relative_v1", 77, profile=linux,
|
| 182 | skip_reason="filesystem capability unavailable: symlink"))
|
| 183 | result = self.verdict(bad)
|
| 184 | self.assertEqual(result["verdict"], "UNMEASURED")
|
| 185 | self.assertIn("symlink_relative_v1", result["unmeasured"][0])
|
| 186 |
|
| 187 | def test_mixed_profiles_are_unmeasured(self):
|
| 188 | rows = replace(healthy_rows(self.expected), conformance_row("eol_lf_v1", 0, profile="linux-cs-ns"))
|
| 189 | self.assertEqual(self.verdict(rows)["verdict"], "UNMEASURED")
|
| 190 |
|
| 191 | def test_missing_rows_harness_errors_and_reader_skips_are_unmeasured(self):
|
| 192 | rows = [r for r in healthy_rows(self.expected) if r["scenario"] != "eol_lf_v1"]
|
| 193 | self.assertEqual(self.verdict(rows)["verdict"], "UNMEASURED")
|
| 194 | self.assertEqual(self.verdict(rows + [conformance_row("eol_lf_v1", 2, failure="harness")])["verdict"], "UNMEASURED")
|
| 195 | reader = conformance_row("eol_lf_v1", 77, skip_reason="leg unmeasured: reader bound")
|
| 196 | self.assertEqual(self.verdict(rows + [reader])["verdict"], "UNMEASURED")
|
| 197 | self.assertEqual(gate.evaluate([], self.expected)["verdict"], "UNMEASURED")
|
| 198 |
|
| 199 | def test_git_rows_never_gate(self):
|
| 200 | rows = healthy_rows(self.expected) + [conformance_row("eol_lf_v1", 1, {"clone_fs": "x"}, subject="git")]
|
| 201 | result = self.verdict(rows)
|
| 202 | self.assertEqual(result["verdict"], "PASS")
|
| 203 | self.assertEqual(len(result["git_reference_failures"]), 1)
|
| 204 |
|
| 205 | def test_list_validation(self):
|
| 206 | good = {"behaviour": "a", "ticket": "fb-1", "failed_legs": {"clone_fs": "x"}}
|
| 207 | bad = [dict(good, ticket="proposed:x"), dict(good, ticket="TODO"), dict(good, ticket="pending:Bad Slug"), dict(good, failed_legs=["clone_fs"]),
|
| 208 | dict(good, failed_legs={"clone_fs": ""}), dict(good, outcome="flaky"), dict(good, fs_profiles=[])]
|
| 209 | with tempfile.TemporaryDirectory() as tmp:
|
| 210 | path = Path(tmp) / "x.json"
|
| 211 | for entry in bad:
|
| 212 | path.write_text(json.dumps({"schema_version": 2, "entries": [entry]}))
|
| 213 | with self.assertRaises(ValueError):
|
| 214 | gate.load_expected(path)
|
| 215 | path.write_text(json.dumps({"schema_version": 1, "entries": [good]}))
|
| 216 | with self.assertRaises(ValueError):
|
| 217 | gate.load_expected(path)
|
| 218 |
|
| 219 | def test_catch_all_patterns_are_rejected(self):
|
| 220 | broad = [".*", ".+", "(.*)", "(?:.*)(?:.*)", "[\\s\\S]*", ".*?", "\\S+.*", "x|.*", ".*error.*",
|
| 221 | ".*panicked.*", ".*corrupt.*", "[a-z ]+", "(panicked|corrupt|.*)", "zzz-sentinel",
|
| 222 | "[0-9a-f]{64}", "\\w{3,}\\d", "[^\\n]{3,}\\.\\s*", "a?b?c?.*", "(error)?.+",
|
| 223 | # Oak/git-shaped catch-alls (leg detail shape "oak <cmd> exited N: error: ...")
|
| 224 | ".*oak.*", "(?:oak|git) .*", "oak .* exited \\d+: .*", ".*error: .*", "oak \\w+ .*",
|
| 225 | "oak (commit|push|pull|clone) .*exited \\d+: error: .*", "command .* failed \\(\\d+\\): .*",
|
| 226 | "oak push --repo exited \\d+: .*", "(?:oak|git) \\S+( \\S+)* exited [0-9]+: (?:error|fatal): .+"]
|
| 227 | for pattern in broad:
|
| 228 | self.assertIsNotNone(gate.pattern_too_broad(pattern), pattern)
|
| 229 | with tempfile.TemporaryDirectory() as tmp:
|
| 230 | path = Path(tmp) / "x.json"
|
| 231 | for pattern in broad:
|
| 232 | entry = {"behaviour": "a", "ticket": "fb-1", "failed_legs": {"clone_fs": pattern}}
|
| 233 | path.write_text(json.dumps({"schema_version": 2, "entries": [entry]}))
|
| 234 | with self.assertRaisesRegex(ValueError, "catch-all", msg=pattern):
|
| 235 | gate.load_expected(path)
|
| 236 | entry = {"behaviour": "a", "ticket": "fb-1", "failed_legs": {"clone_fs": "(unclosed"}}
|
| 237 | path.write_text(json.dumps({"schema_version": 2, "entries": [entry]}))
|
| 238 | with self.assertRaisesRegex(ValueError, "invalid"):
|
| 239 | gate.load_expected(path)
|
| 240 |
|
| 241 | def test_specific_patterns_are_accepted(self):
|
| 242 | specific = ["oak pull exited 1: error: IO error: File exists \\(os error 17\\)",
|
| 243 | "missing=\\['file\\.txt'\\]", "exit(ed)? \\d+", "(foo|bar) failed at \\d+"]
|
| 244 | for pattern in specific:
|
| 245 | self.assertIsNone(gate.pattern_too_broad(pattern), pattern)
|
| 246 | # every shipped entry is specific
|
| 247 | for entry in gate.load_expected()["entries"].values():
|
| 248 | for pattern in entry["patterns"].values():
|
| 249 | self.assertIsNone(gate.pattern_too_broad(pattern.pattern), pattern.pattern)
|
| 250 |
|
| 251 |
|
| 252 | class WiringTests(unittest.TestCase):
|
| 253 | def setUp(self):
|
| 254 | self.expected = gate.load_expected()
|
| 255 |
|
| 256 | def test_devloop_lane_is_default_and_maps_verdicts(self):
|
| 257 | self.assertIn("conformance", devloop.LANES)
|
| 258 | regressions, notes = devloop.conformance_checks(healthy_rows(self.expected))
|
| 259 | self.assertEqual(regressions, [])
|
| 260 | self.assertFalse(any(n.startswith("UNMEASURED") for n in notes))
|
| 261 | rows = replace(healthy_rows(self.expected), conformance_row("eol_lf_v1", 1, {"pull_fs": "x"}))
|
| 262 | regressions, notes = devloop.conformance_checks(rows)
|
| 263 | self.assertTrue(regressions and regressions[0].startswith("[conformance] CONFORMANCE eol_lf_v1"))
|
| 264 | rows = [r for r in healthy_rows(self.expected) if r["scenario"] != "eol_lf_v1"]
|
| 265 | regressions, notes = devloop.conformance_checks(rows)
|
| 266 | self.assertTrue(any(n.startswith("UNMEASURED conformance") for n in notes))
|
| 267 |
|
| 268 | def gate_output(self, rows):
|
| 269 | with tempfile.TemporaryDirectory() as tmp:
|
| 270 | path = Path(tmp) / "rows.jsonl"
|
| 271 | path.write_text("".join(json.dumps(r) + "\n" for r in rows))
|
| 272 | with contextlib.redirect_stdout(io.StringIO()) as out:
|
| 273 | code = publish_gate.main([str(path)])
|
| 274 | return code, out.getvalue()
|
| 275 |
|
| 276 | def oak_row(self, sha=SHA, **extra):
|
| 277 | return {"subject": "oak_installed", "binary_sha256": sha, "returncode": 0,
|
| 278 | "source": {"oak_hash": "abc", "oak_status": ""}, **extra}
|
| 279 |
|
| 280 | def test_requires_conformance_for_oak_rows(self):
|
| 281 | code, out = self.gate_output([self.oak_row()])
|
| 282 | self.assertIn("FAIL conformance: Oak rows published without", out)
|
| 283 | code, out = self.gate_output([self.oak_row(), *healthy_rows(self.expected)])
|
| 284 | self.assertIn("PASS [bbbbbbbbbbbb] conformance (oak, darwin-ci-ni): PASS", out)
|
| 285 | self.assertNotIn("FAIL conformance", out)
|
| 286 |
|
| 287 | def test_oak_rows_without_binary_identity_fail(self):
|
| 288 | anonymous = self.oak_row()
|
| 289 | anonymous.pop("binary_sha256")
|
| 290 | code, out = self.gate_output([anonymous, *healthy_rows(self.expected)])
|
| 291 | self.assertIn("Oak rows lack binary identity", out)
|
| 292 | via_details = self.oak_row(subject_details={"oak_installed": {"binary_sha256": SHA}})
|
| 293 | via_details.pop("binary_sha256")
|
| 294 | code, out = self.gate_output([via_details, *healthy_rows(self.expected)])
|
| 295 | self.assertNotIn("lack binary identity", out)
|
| 296 |
|
| 297 | def test_rows_of_another_binary_never_satisfy_or_mix(self):
|
| 298 | other = "c" * 64
|
| 299 | code, out = self.gate_output([self.oak_row(sha=other), *healthy_rows(self.expected)])
|
| 300 | self.assertIn(f"conformance[{other[:12]}]: UNMEASURED", out)
|
| 301 | # A good binary's rows cannot mask a regressed binary's row, and vice versa.
|
| 302 | mixed = healthy_rows(self.expected) + replace(healthy_rows(self.expected, sha=other),
|
| 303 | conformance_row("eol_lf_v1", 1, {"pull_fs": "x"}, sha=other))
|
| 304 | code, out = self.gate_output([self.oak_row(), self.oak_row(sha=other), *mixed])
|
| 305 | self.assertIn(f"conformance[{other[:12]}]: REGRESSED", out)
|
| 306 | self.assertNotIn(f"conformance[{SHA[:12]}]: REGRESSED", out)
|
| 307 |
|
| 308 | def test_publish_requires_a_recorded_profile(self):
|
| 309 | linux = [dict(row, fs_profile="linux-cs-ns") for row in healthy_rows(self.expected, profile="linux-cs-ns")]
|
| 310 | code, out = self.gate_output([self.oak_row(), *linux])
|
| 311 | self.assertEqual(code, 1)
|
| 312 | self.assertIn("has no recorded coverage", out)
|
| 313 | skipped = [conformance_row(b, 77, None, profile="linux-cs-ns") for b in ALL]
|
| 314 | code, out = self.gate_output([self.oak_row(), *skipped])
|
| 315 | self.assertIn("UNMEASURED", out)
|
| 316 | self.assertNotIn("PASS [", out)
|
| 317 | missing = healthy_rows(self.expected)
|
| 318 | for row in missing:
|
| 319 | row.pop("fs_profile")
|
| 320 | code, out = self.gate_output([self.oak_row(), *missing])
|
| 321 | self.assertIn("FAIL conformance[", out)
|
| 322 | self.assertNotIn("PASS [", out)
|
| 323 |
|
| 324 | def test_pending_tickets_refused_by_publish_but_only_noted_by_devloop(self):
|
| 325 | base = gate.load_expected()
|
| 326 | for outcome in ("intermittent", "bug", "refused"):
|
| 327 | entries = dict(base["entries"])
|
| 328 | entries["large_file_chunked_v1"] = dict(entries["large_file_chunked_v1"], ticket="pending:db-locked",
|
| 329 | outcome=outcome)
|
| 330 | expected = dict(base, entries=entries)
|
| 331 | self.assertEqual(gate.pending_tickets(expected), [("large_file_chunked_v1", "pending:db-locked", outcome)])
|
| 332 | with mock.patch.object(gate, "load_expected", return_value=expected):
|
| 333 | code, out = self.gate_output([self.oak_row(), *healthy_rows(base)])
|
| 334 | self.assertEqual(code, 1, outcome)
|
| 335 | self.assertIn("unfiled pending: tickets", out)
|
| 336 | self.assertIn("large_file_chunked_v1 (pending:db-locked", out)
|
| 337 | regressions, notes = devloop.conformance_checks(healthy_rows(base))
|
| 338 | # devloop still runs and only notes the placeholder
|
| 339 | self.assertEqual(regressions, [])
|
| 340 | self.assertEqual(gate.pending_tickets(base), [])
|
| 341 |
|
| 342 | def test_stale_list_fails(self):
|
| 343 | rows = replace(healthy_rows(self.expected), conformance_row("path_tab_v1", 0))
|
| 344 | code, out = self.gate_output([self.oak_row(), *rows])
|
| 345 | self.assertIn("expected-failures list is stale", out)
|
| 346 |
|
| 347 |
|
| 348 | if __name__ == "__main__":
|
| 349 | unittest.main()
|