Viewing files at commit 5717cc9fc7edCommit detailsView latest

Browse files
tests/test_robustness_invariants.py 480 lines · 25.8 KB · python
1
"""Robustness oracles: fault-fired accounting, torn worktrees, actionability, disk fill, row validation."""
2
3
from __future__ import annotations
4
5
import json
6
import os
7
import platform
8
import shutil
9
import subprocess
10
import sys
11
import tempfile
12
import unittest
13
from pathlib import Path
14
15
ROOT = Path(__file__).resolve().parents[1]
16
sys.path.insert(0, str(ROOT / "suites" / "robustness"))
17
sys.path.insert(0, str(ROOT / "scripts"))
18
19
import run_robustness  # noqa: E402
20
from robustness import FAULT_NOT_FIRED, HARNESS_ERROR, HELD, VIOLATED, crashpoints, known_bugs  # noqa: E402
21
from robustness.concurrency import reported_pushed_head  # noqa: E402
22
from robustness.crash import run_time_point, run_trial, time_fault  # noqa: E402
23
from robustness.engine import actionable, classify, torn_state, torn_verdict, torn_worktree  # noqa: E402
24
from robustness.fixtures import build_git  # noqa: E402
25
from robustness.procs import Result, run_killed_after  # noqa: E402
26
from robustness.readers import worktree_manifest  # noqa: E402
27
28
from oakbench.rows import SKIP_RETURNCODE, validate_rows  # noqa: E402
29
from oakbench.subjects import which_measured_git  # noqa: E402
30
31
GIT = which_measured_git()
32
33
34
def result(rc, stdout="", stderr=""):
35
    return Result(["x"], rc, stdout, stderr, 1.0)
36
37
38
def f(tag):
39
    return {"kind": "file", "size": len(tag), "sha256": tag, "executable": False}
40
41
42
class FaultFiredTests(unittest.TestCase):
43
    def test_not_fired_is_never_held(self):
44
        self.assertEqual(classify({"a": True}, fault_fired=False), FAULT_NOT_FIRED)
45
        self.assertEqual(classify({"a": False}, fault_fired=False), FAULT_NOT_FIRED)
46
        self.assertEqual(classify({"a": True}, fault_fired=True), HELD)
47
        self.assertEqual(classify({"a": True}, fault_fired=None), HELD)
48
        self.assertEqual(classify({"a": True}, harness_error="x", fault_fired=False), HARNESS_ERROR)
49
50
    def test_reader_exception_is_harness_error_not_violation(self):
51
        self.assertEqual(classify({"a": None}, reader_errors=["reader error: boom"]), HARNESS_ERROR)
52
        # an independent subject failure still wins
53
        self.assertEqual(classify({"repo_opens": False, "a": None}, reader_errors=["x"]), VIOLATED)
54
55
    def test_process_that_exits_before_the_kill_is_not_fired(self):
56
        with tempfile.TemporaryDirectory() as temp:
57
            res = run_killed_after(["/bin/sh", "-c", "exit 0"], Path(temp), dict(os.environ), 2.0)
58
            self.assertEqual(res.returncode, 0)
59
60
            class Stub:
61
                def op_argv(self):
62
                    return ["/bin/sh", "-c", "exit 0"]
63
64
                def op_cwd(self):
65
                    return Path(temp)
66
67
                class adapter:  # noqa: N801
68
                    @staticmethod
69
                    def env():
70
                        return dict(os.environ)
71
72
            _, fired, point = time_fault(1.0)(Stub())
73
            self.assertFalse(fired)
74
            self.assertEqual(point, "t=1000.0ms")
75
76
    def test_process_killed_mid_run_is_fired(self):
77
        with tempfile.TemporaryDirectory() as temp:
78
            res = run_killed_after(["/bin/sleep", "5"], Path(temp), dict(os.environ), 0.05)
79
            self.assertEqual(res.returncode, -9)
80
            self.assertTrue(res.killed_by_harness)
81
82
83
@unittest.skipUnless(GIT and shutil.which(GIT), "real git required")
84
class NotFiredEndToEndTests(unittest.TestCase):
85
    def test_git_commit_that_finishes_before_the_kill_is_fault_not_fired(self):
86
        with tempfile.TemporaryDirectory() as temp:
87
            temp = Path(temp)
88
            fx = build_git(GIT, "commit", temp / "fx")
89
            from robustness.crash import calibrate
90
91
            calib = calibrate(fx, GIT, temp / "trials", None)
92
            self.assertTrue(calib.ok, calib.detail)
93
            row = run_trial(fx, GIT, temp / "trials", "late", calib, time_fault(60.0),
94
                            lambda trial, c: shutil.rmtree(trial.dir, ignore_errors=True))
95
            self.assertIs(row["fault_fired"], False)
96
            self.assertEqual(row["classification"], FAULT_NOT_FIRED)
97
            self.assertIn("did not fire", row["skip_reason"])
98
            args = run_robustness.parse_args(["--subjects", "git", "--results", str(temp / "r"), "--workdir",
99
                                              str(temp / "w"), "--bench-id", "20260929T000000Z", "--git-bin", GIT])
100
            runner = run_robustness.Runner(args)
101
            runner.versions = {"git": "git test"}
102
            emitted = runner.emit("git", "crash_time_v1", "commit", "crash_time", row)
103
            self.assertEqual(emitted["returncode"], SKIP_RETURNCODE)
104
            self.assertTrue(emitted["skipped"])
105
            self.assertEqual(validate_rows([emitted], "robustness"), [])
106
107
    def test_time_point_re_aims_from_observed_duration(self):
108
        with tempfile.TemporaryDirectory() as temp:
109
            temp = Path(temp)
110
            fx = build_git(GIT, "commit", temp / "fx")
111
            from robustness.crash import calibrate
112
113
            calib = calibrate(fx, GIT, temp / "trials", None)
114
            calib.elapsed_ms = 120000.0  # a stale calibration: the first kill lands long after the op
115
            row = run_time_point(fx, GIT, temp / "trials", "reaim", calib, 0.5,
116
                                 lambda trial, c: shutil.rmtree(trial.dir, ignore_errors=True))
117
            attempts = row["observations"]["time_attempts"]
118
            self.assertIs(attempts[0]["fired"], False)
119
            self.assertGreater(len(attempts), 1)
120
            self.assertLess(attempts[1]["delay_ms"], attempts[0]["delay_ms"])
121
            if row["fault_fired"] is False:
122
                self.assertEqual(row["classification"], FAULT_NOT_FIRED)
123
            else:
124
                self.assertIn(row["classification"], (HELD, VIOLATED))
125
126
127
class TornWorktreeTests(unittest.TestCase):
128
    PRE = {"a": f("a0"), "b": f("b0"), "c": f("c0")}
129
    POST = {"a": f("a1"), "b": f("b1"), "c": f("c0"), "n": f("n1")}
130
131
    def test_split(self):
132
        wt = {"a": f("a1"), "b": f("b0"), "c": f("c0"), "a.tmp-1": f("x")}
133
        split = torn_worktree(wt, self.PRE, self.POST)
134
        self.assertEqual(split["op_output"], ["a"])
135
        self.assertEqual(split["pre_side"], ["b", "n"])
136
        self.assertEqual(split["stray"], ["a.tmp-1"])
137
138
    def test_states(self):
139
        pre_split = torn_worktree(dict(self.PRE), self.PRE, self.POST)
140
        post_split = torn_worktree(dict(self.POST), self.PRE, self.POST)
141
        self.assertFalse(torn_state(pre_split, "pre"))
142
        self.assertFalse(torn_state(post_split, "post"))
143
        # every file already on the target side while the ref still names the old side
144
        self.assertTrue(torn_state(post_split, "pre"))
145
        # ref moved but the worktree lags
146
        self.assertTrue(torn_state(pre_split, "post"))
147
        mixed = torn_worktree({"a": f("a1"), "b": f("b0"), "c": f("c0")}, self.PRE, self.POST)
148
        self.assertTrue(torn_state(mixed, "pre"))
149
        self.assertTrue(torn_state(mixed, None))
150
        # user edits to files the op does not touch are not the op's output
151
        user = torn_worktree({**self.PRE, "c": f("mine")}, self.PRE, self.POST)
152
        self.assertFalse(torn_state(user, "pre"))
153
154
    def _verdict(self, changes, in_progress, head="old"):
155
        class Adapter:
156
            def head(self, repo):
157
                return head
158
159
            def status_state(self, repo):
160
                return changes, in_progress, result(0 if changes is not None else 4)
161
162
        class T:
163
            adapter = Adapter()
164
165
        from unittest import mock
166
        with mock.patch("robustness.readers.worktree_manifest",
167
                        return_value={"a": f("a1"), "b": f("b0"), "c": f("c0")}):
168
            return torn_verdict(T(), Path("."), self.PRE, self.POST, "old")
169
170
    def test_partial_output_presented_as_user_changes_fails(self):
171
        ok, detail = self._verdict(True, False)
172
        self.assertIs(ok, False)
173
        self.assertEqual(detail["torn"], "PARTIAL_OP_OUTPUT_AS_USER_CHANGES")
174
        self.assertEqual(detail["ref_side"], "pre")
175
176
    def test_flagged_in_progress_or_refused_passes(self):
177
        self.assertIs(self._verdict(True, True)[0], True)
178
        self.assertIs(self._verdict(None, False)[0], True)
179
180
181
@unittest.skipUnless(GIT and shutil.which(GIT), "real git required")
182
class TornWorktreeRealGitTests(unittest.TestCase):
183
    def test_half_switched_git_worktree_is_torn_but_merge_head_is_not(self):
184
        with tempfile.TemporaryDirectory() as temp:
185
            temp = Path(temp)
186
            fx = build_git(GIT, "switch", temp / "fx")
187
            client = fx.pristine / "client"
188
            pre = worktree_manifest(client)
189
            from robustness.engine import Trial
190
191
            trial = Trial(fx, GIT, temp / "trials", "torn")
192
            trial.adapter.must([GIT, "switch", "-q", fx.meta["target"]], trial.client)
193
            post = worktree_manifest(trial.client)
194
            trial.adapter.must([GIT, "switch", "-q", fx.branch], trial.client)
195
            # simulate a crash mid-checkout: some files already hold the target side, HEAD is old
196
            for name in sorted(p for p in post if pre.get(p) != post.get(p))[:5]:
197
                src = subprocess.run([GIT, "show", f"{fx.meta['target']}:{name}"], cwd=trial.client,
198
                                     capture_output=True, check=True).stdout
199
                (trial.client / name).parent.mkdir(parents=True, exist_ok=True)
200
                (trial.client / name).write_bytes(src)
201
            ok, detail = torn_verdict(trial, trial.client, pre, post, fx.meta["pre_head"])
202
            self.assertIs(ok, False, detail)
203
            (trial.client / ".git" / "MERGE_HEAD").write_text(fx.meta["pre_head"] + "\n")
204
            ok, detail = torn_verdict(trial, trial.client, pre, post, fx.meta["pre_head"])
205
            self.assertIs(ok, True, detail)
206
            trial.cleanup()
207
208
209
@unittest.skipUnless(GIT, "git required")
210
class GitStaleLockTests(unittest.TestCase):
211
    """M1: git locks that make the next commit refuse count as 'in progress', like index.lock.
212
213
    Synthetic, interposer-free: a real repo with a lock file planted, and real
214
    `git commit` confirming which locks actually block."""
215
216
    def repo(self, temp: Path) -> Path:
217
        repo = temp / "r"
218
        env = {**os.environ, "GIT_CONFIG_GLOBAL": "/dev/null", "GIT_CONFIG_NOSYSTEM": "1"}
219
        for argv in (["init", "-q", "-b", "main", str(repo)],):
220
            subprocess.run([GIT, *argv], check=True, env=env)
221
        for argv in (["config", "user.email", "a@b"], ["config", "user.name", "a"]):
222
            subprocess.run([GIT, *argv], cwd=repo, check=True, env=env)
223
        (repo / "f").write_text("a\n")
224
        subprocess.run([GIT, "add", "f"], cwd=repo, check=True, env=env)
225
        subprocess.run([GIT, "commit", "-qm", "a"], cwd=repo, check=True, env=env)
226
        subprocess.run([GIT, "branch", "other"], cwd=repo, check=True, env=env)
227
        subprocess.run([GIT, "tag", "t1"], cwd=repo, check=True, env=env)
228
        self.env = env
229
        return repo
230
231
    def test_blocking_locks_match_what_git_commit_refuses(self):
232
        from robustness.adapters import GitAdapter
233
234
        blocking = ("index.lock", "HEAD.lock", "refs/heads/main.lock")
235
        harmless = ("config.lock", "refs/heads/other.lock", "refs/tags/t1.lock", "ORIG_HEAD.lock")
236
        with tempfile.TemporaryDirectory() as temp:
237
            repo = self.repo(Path(temp))
238
            adapter = GitAdapter(GIT, Path(temp) / "home")
239
            for n, lock in enumerate(blocking + harmless):
240
                path = repo / ".git" / lock
241
                path.parent.mkdir(parents=True, exist_ok=True)
242
                path.write_text("")
243
                (repo / "f").write_text(f"change {n}\n")  # a pending change status lists
244
                changes, in_progress, _ = adapter.status_state(repo)
245
                commit = subprocess.run([GIT, "commit", "-qam", f"c{n}"], cwd=repo, capture_output=True,
246
                                        text=True, env=self.env)
247
                path.unlink()
248
                if lock in blocking:
249
                    self.assertTrue(in_progress, lock)
250
                    self.assertEqual(adapter.blocking_locks(repo), [], lock)
251
                    self.assertNotEqual(commit.returncode, 0, lock)
252
                    self.assertIn("remove the file manually", commit.stderr, lock)
253
                    self.assertTrue(actionable(result(commit.returncode, stderr=commit.stderr)), lock)
254
                else:
255
                    self.assertTrue(changes, lock)
256
                    self.assertFalse(in_progress, lock)
257
                    self.assertEqual(commit.returncode, 0, (lock, commit.stderr))
258
259
    def test_half_switched_worktree_with_head_lock_is_flagged_not_torn(self):
260
        # `git crash_syscall switch before rename#2`: worktree/index on the target, HEAD
261
        # still names the old branch, HEAD.lock left behind.
262
        with tempfile.TemporaryDirectory() as temp:
263
            temp = Path(temp)
264
            fx = build_git(GIT, "switch", temp / "fx")
265
            client = fx.pristine / "client"
266
            pre = worktree_manifest(client)
267
            from robustness.engine import Trial
268
269
            trial = Trial(fx, GIT, temp / "trials", "torn")
270
            trial.adapter.must([GIT, "switch", "-q", fx.meta["target"]], trial.client)
271
            post = worktree_manifest(trial.client)
272
            (trial.client / ".git" / "HEAD").write_text(f"ref: refs/heads/{fx.branch}\n")
273
            ok, detail = torn_verdict(trial, trial.client, pre, post, fx.meta["pre_head"])
274
            self.assertIs(ok, False, detail)  # without the lock: presented as user changes
275
            (trial.client / ".git" / "HEAD.lock").write_text(f"ref: refs/heads/{fx.meta['target']}\n")
276
            ok, detail = torn_verdict(trial, trial.client, pre, post, fx.meta["pre_head"])
277
            self.assertIs(ok, True, detail)
278
            self.assertEqual(detail["torn"], "flagged in progress")
279
            (trial.client / ".git" / "HEAD.lock").unlink()
280
            (trial.client / ".git" / "config.lock").write_text("")  # does not block a commit
281
            ok, detail = torn_verdict(trial, trial.client, pre, post, fx.meta["pre_head"])
282
            self.assertIs(ok, False, detail)
283
            trial.cleanup()
284
285
286
class ActionableTests(unittest.TestCase):
287
    def test_corruption_internal_and_panic_are_not_actionable(self):
288
        for text in ("error: failed to open oak database: file is not a database",
289
                     "error: internal error in oak core",
290
                     "error: push rejected: corrupted object",
291
                     "fatal: not a git repository (or any of the parent directories): .git",
292
                     "error: Database error: database disk image is malformed",
293
                     "thread 'main' panicked at src/lib.rs:9:5",
294
                     "error: internal error; please try again"):
295
            self.assertFalse(actionable(result(1, stderr=text)), text)
296
        internal = {"error": {"code": "internal", "message": "store malformed"}}
297
        self.assertFalse(actionable(result(1, stdout=json.dumps(internal))))
298
        generic = {"error": {"code": "error", "message": "something failed", "recommended_next_commands": []}}
299
        self.assertFalse(actionable(result(1, stdout=json.dumps(generic))))
300
301
    def test_recovery_commands_and_typed_codes_are_actionable(self):
302
        for text in ("error: working tree has uncommitted changes — run `oak commit` to keep them",
303
                     "Remove this directory and re-run `oak clone`.",
304
                     "The remote branch has commits this clone doesn't have.\nRun 'oak pull' to bring them in",
305
                     "remove the file manually to continue.",
306
                     "hint: Updates were rejected ... ! [rejected] main -> main (fetch first)",
307
                     "error: repository locked by another process; try again"):
308
            self.assertTrue(actionable(result(4, stderr=text)), text)
309
        rec = {"error": {"code": "error", "message": "x", "recommended_next_commands": ["oak status --json"]}}
310
        self.assertTrue(actionable(result(6, stdout=json.dumps(rec))))
311
        self.assertTrue(actionable(result(5, stdout=json.dumps({"error": {"code": "merge_in_progress"}}))))
312
        self.assertFalse(actionable(result(0, stderr="run `oak pull`")))
313
314
    def test_git_conflict_and_fast_forward_outcomes_are_actionable(self):
315
        conflict = ("Auto-merging f.txt\nCONFLICT (content): Merge conflict in f.txt\n"
316
                    "Automatic merge failed; fix conflicts and then commit the result.\n")
317
        ff_bare = "fatal: Not possible to fast-forward, aborting.\n"
318
        ff_hints = ("hint: Diverging branches can't be fast-forwarded, you need to either:\nhint:\n"
319
                    "hint: \tgit merge --no-ff\nhint:\nhint: or:\nhint:\nhint: \tgit rebase\nhint:\n"
320
                    "hint: Disable this message with \"git config set advice.diverging false\"\n" + ff_bare)
321
        for text in (conflict, ff_bare, ff_hints):
322
            self.assertTrue(actionable(result(1, stdout=text if text is conflict else "",
323
                                              stderr="" if text is conflict else text)), text)
324
            self.assertTrue(actionable(result(128, stderr=text)), text)
325
326
    def test_git_outcome_hints_do_not_launder_broken_repositories(self):
327
        conflict = "Automatic merge failed; fix conflicts and then commit the result."
328
        ff = "fatal: Not possible to fast-forward, aborting."
329
        for broken in ("fatal: not a git repository (or any of the parent directories): .git",
330
                       "error: object file .git/objects/ab/cd is empty\nfatal: loose object abcd is corrupt",
331
                       "error: RPC failed; HTTP 500 curl 22 The requested URL returned error: 500",
332
                       "fatal: internal error in merge-ort",
333
                       "thread 'main' panicked at src/merge.rs:1:1",
334
                       "error: Database error: database disk image is malformed"):
335
            for hint in (conflict, ff):
336
                self.assertFalse(actionable(result(1, stderr=broken + "\n" + hint)), (broken, hint))
337
        # the phrase must be git's own sentence, not any mention of a failed merge
338
        self.assertFalse(actionable(result(1, stderr="error: automatic merge failed")))
339
        self.assertFalse(actionable(result(1, stderr="error: fast-forward not possible")))
340
341
342
@unittest.skipUnless(platform.system() == "Darwin" and shutil.which("hdiutil"), "macOS RAM disk required")
343
class DiskFillTests(unittest.TestCase):
344
    def test_fill_leaves_less_than_one_block_and_records_measured_free(self):
345
        from robustness.envfaults import RamDisk, fill, free_bytes
346
347
        with tempfile.TemporaryDirectory() as temp:
348
            mount = Path(temp) / "mnt"
349
            with RamDisk(mount, mib=8):
350
                _, free = fill(mount, 0)
351
                self.assertLess(free, 4096)
352
                self.assertEqual(free, free_bytes(mount))
353
                with self.assertRaises(OSError):
354
                    with (mount / "more.bin").open("wb") as fh:
355
                        fh.write(b"x" * 8192)
356
                        fh.flush()
357
                        os.fsync(fh.fileno())
358
                (mount / "more.bin").unlink(missing_ok=True)
359
                (mount / "p4-filler.bin").unlink()
360
                _, free = fill(mount, 64)
361
                self.assertGreaterEqual(free, 60 * 1024)
362
                self.assertLess(free, 72 * 1024)
363
364
365
class EnvFaultFiredTests(unittest.TestCase):
366
    def test_disk_full_needs_evidence_not_just_a_nonzero_exit(self):
367
        from robustness.envfaults import disk_full_fired
368
369
        self.assertEqual(disk_full_fired(1 << 20, 4096, result(1, stderr="error: something else"))[0], False)
370
        self.assertEqual(disk_full_fired(1 << 20, 4096, result(0))[0], False)
371
        for text in ("error: IO error: No space left on device (os error 28)",
372
                     "fatal: unable to write new index file: ENOSPC",
373
                     "Error: database or disk is full", "sqlite: SQLITE_FULL"):
374
            fired, evidence = disk_full_fired(1 << 20, 4096, result(1, stderr=text))
375
            self.assertTrue(fired, text)
376
            self.assertIn("ENOSPC", evidence)
377
        fired, evidence = disk_full_fired(0, 4096, result(1, stderr="error: unable to open database file"))
378
        self.assertTrue(fired)
379
        self.assertIn("< op need", evidence)
380
        self.assertTrue(disk_full_fired(0, 4096, result(0))[0])  # measured short: fired even if it succeeded
381
382
    def test_readonly_fires_only_when_a_write_is_attempted(self):
383
        from robustness.envfaults import readonly_fired
384
385
        self.assertEqual(readonly_fired(True, True, result(1, stderr="Permission denied"))[0], False)
386
        self.assertEqual(readonly_fired(False, None, result(1, stderr="error: Permission denied (os error 13)"))[0],
387
                         True)
388
        self.assertTrue(readonly_fired(False, False, result(1, stderr="attempt to write a readonly database"))[0])
389
        self.assertTrue(readonly_fired(False, True, result(0))[0])
390
        fired, evidence = readonly_fired(False, False, result(0))
391
        self.assertIs(fired, False)
392
        self.assertTrue(evidence.startswith("not exercised"))
393
        self.assertIsNone(readonly_fired(False, None, result(0))[0])
394
395
    def test_write_snapshot_sees_writes_under_the_locked_path(self):
396
        from robustness.envfaults import write_snapshot
397
398
        with tempfile.TemporaryDirectory() as temp:
399
            d = Path(temp) / "d3"
400
            (d / "sub").mkdir(parents=True)
401
            (d / "a").write_text("a")
402
            before = write_snapshot(d, "subdir")
403
            (d / "sub" / "deep").write_text("x")  # not blocked by a read-only d3 itself
404
            (d / "a").write_text("changed in place")  # in-place write: allowed
405
            self.assertEqual(write_snapshot(d, "subdir"), before)
406
            (d / "b").write_text("new entry")
407
            self.assertNotEqual(write_snapshot(d, "subdir"), before)
408
            store = write_snapshot(d, "store")
409
            os.utime(d / "sub" / "deep", ns=(1, 1))
410
            self.assertNotEqual(write_snapshot(d, "store"), store)
411
            self.assertIsNone(write_snapshot(d / "missing", "store"))
412
413
    @unittest.skipUnless(GIT and os.geteuid() != 0, "git and a non-root user required")
414
    def test_git_readonly_rows_record_fired_or_not_exercised(self):
415
        from robustness.crash import calibrate
416
        from robustness.envfaults import readonly_exercised, readonly_trial
417
418
        with tempfile.TemporaryDirectory() as temp:
419
            temp = Path(temp)
420
            fx = build_git(GIT, "commit", temp / "fx")
421
            calib = calibrate(fx, GIT, temp / "trials", None)
422
            exercised, detail = readonly_exercised(fx, GIT, temp / "trials", "probe", "store")
423
            self.assertTrue(exercised, detail)
424
            row = readonly_trial(fx, GIT, temp / "trials", "ro", calib, "store", exercised, detail)
425
            self.assertIs(row["fault_fired"], True, row)
426
            # a lock-down no write reaches is recorded as not exercised, never held
427
            row = readonly_trial(fx, GIT, temp / "trials", "ro2", calib, "store", False, {})
428
            if row["fault_fired"] is False:
429
                self.assertEqual(row["classification"], FAULT_NOT_FIRED)
430
                self.assertTrue(row["skip_reason"].startswith("not exercised"))
431
            else:  # git itself reported the permission error
432
                self.assertIn("permission", row["observations"]["fault_fired_evidence"])
433
434
435
class RowValidationTests(unittest.TestCase):
436
    def _row(self, **kw):
437
        row = {"bench_id": "20260929T000000Z", "scenario": "crash_time_v1", "operation": "commit", "run": 0,
438
               "subject": "git", "subject_kind": "git", "elapsed_ms": 1.0, "returncode": 0, "classification": "held",
439
               "fault_family": "crash_time", "fault_point": "t=1ms", "fault_fired": True, "violations": []}
440
        row.update(kw)
441
        return row
442
443
    def test_rejects_unknown_and_inconsistent_classifications(self):
444
        self.assertEqual(validate_rows([self._row()], "robustness"), [])
445
        self.assertTrue(validate_rows([self._row(classification="bogus")], "robustness"))
446
        self.assertTrue(validate_rows([self._row(returncode=1)], "robustness"))
447
        self.assertTrue(validate_rows([self._row(classification="violated", returncode=0)], "robustness"))
448
        self.assertTrue(validate_rows([self._row(fault_fired=False)], "robustness"))
449
        self.assertTrue(validate_rows([self._row(violations=["x"])], "robustness"))
450
        not_fired = self._row(classification="fault_not_fired", returncode=77, fault_fired=False, skipped=True,
451
                              skip_reason="op exited before the kill")
452
        self.assertEqual(validate_rows([not_fired], "robustness"), [])
453
        self.assertTrue(validate_rows([{**not_fired, "returncode": 0, "skipped": False}], "robustness"))
454
455
456
class MiscTests(unittest.TestCase):
457
    def test_known_bug_tags(self):
458
        self.assertEqual(known_bugs("oak", "crash_time_v1", "pull_ff", ["worktree_not_torn"]), ["fb-535"])
459
        self.assertEqual(known_bugs("oak", "crash_syscall_v1", "switch", ["worktree_not_torn", "x"]), ["fb-535"])
460
        self.assertEqual(known_bugs("oak", "conc_same_branch_v1", "concurrent.push",
461
                                    ["no_lost_acknowledged_write", "linearizable"]), ["fb-534"])
462
        self.assertEqual(known_bugs("git", "crash_time_v1", "switch", ["worktree_not_torn"]), [])
463
        self.assertEqual(known_bugs("oak", "crash_time_v1", "commit", ["repo_opens"]), [])
464
465
    def test_no_boundary_reasons_are_subject_specific(self):
466
        self.assertNotIn("git", crashpoints.NO_BOUNDARY_REASON["oak"])
467
        self.assertIn("git", crashpoints.NO_BOUNDARY_REASON["git"])
468
        inst = crashpoints.CrashInstrument(Path("x"), None, Path("g"), None, {}, {"oak": "codesign failed"})
469
        self.assertEqual(crashpoints.unavailable_reason(inst, "", "oak"), "codesign failed")
470
        self.assertEqual(crashpoints.unavailable_reason(inst, "", "git"), "")
471
        self.assertEqual(crashpoints.unavailable_reason(None, "no clang", "git"), "no clang")
472
473
    def test_reported_pushed_head(self):
474
        self.assertEqual(reported_pushed_head(result(0, stdout=json.dumps({"pushed": True, "pushed_head": "abc"}))),
475
                         "abc")
476
        self.assertIsNone(reported_pushed_head(result(0, stdout="plain text")))
477
478
479
if __name__ == "__main__":
480
    unittest.main()