diff options
| author | godosa <godosa@godosa.eu> | 2026-10-07 07:27:17 +0200 |
|---|---|---|
| committer | godosa <godosa@godosa.eu> | 2026-10-07 07:27:17 +0200 |
| commit | 81d4e80fd5aabe4e80f58e960affa795cf7d34ec (patch) | |
| tree | e98eeac2af6af63aa4287bba1f6d4a3af26b5727 /tests/test_batch.py | |
| download | workflow-81d4e80fd5aabe4e80f58e960affa795cf7d34ec.tar.gz workflow-81d4e80fd5aabe4e80f58e960affa795cf7d34ec.zip | |
workflow: initial public history
Diffstat (limited to 'tests/test_batch.py')
| -rw-r--r-- | tests/test_batch.py | 534 |
1 files changed, 534 insertions, 0 deletions
diff --git a/tests/test_batch.py b/tests/test_batch.py new file mode 100644 index 0000000..2f0e834 --- /dev/null +++ b/tests/test_batch.py @@ -0,0 +1,534 @@ +import io +import shlex +import subprocess +import sys +import tempfile +import threading +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path + +HERE = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(HERE)) +sys.path.insert(0, str(HERE / "tests")) + +import wf_res # noqa: E402 +from test_res_io import Box, hist_rec, seed_history # noqa: E402 + +OUT = "out/wf-batch-2026-10-01-1400.md" + + +class BatchBase(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.box = Box(Path(self._tmp.name)) + bin_ = self.box.tmp / "bin" + bin_.mkdir() + self.claude = bin_ / "claude" + self.claude.write_text("#!/bin/sh\n") + self.claude.chmod(0o755) + self.box.caller["PATH"] = f"{bin_}:/usr/bin" + + def tearDown(self): + self._tmp.cleanup() + + def batch(self, *argv): + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + code = wf_res.batch_main(list(argv), self.box.env) + return code, out.getvalue(), err.getvalue() + + def unit_argv(self): + return self.box.fake.ran("systemd-run")[0] + + +class Start(BatchBase): + def test_starts_claude_print_unit(self): + code, out, err = self.batch("2", "--lanes", "fast,slow") + self.assertEqual((code, err), (0, "")) + log = self.box.env.state / "logs" / "r-1.log" + self.assertEqual(out, f"r-1 started; log {log}; ETA ~20:00 (estimate: not killed when over)\n" + f"summary: {OUT} · status: wf batch --status\n") + argv = self.unit_argv() + i = argv.index(str(self.claude)) + self.assertEqual(argv[i - 1], "sh") + self.assertEqual(argv[i + 1:-1], ["-p", "--model", "opus", "--permission-mode", "auto", + "--permission-prompts", "none"]) + self.assertIn("--setenv=CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=21600000", argv) + self.assertIn(f"MemoryMax={4 * 1024 ** 3}", argv) + e = self.box.ledger().get("r-1") + self.assertEqual((e.title, e.est_min, e.project), ("wf-batch", 360, "proj")) + + def test_prompt_from_template(self): + self.batch("2", "--lanes", "fast,slow") + prompt = self.unit_argv()[-1] + self.assertIn("for up to 2 tasks (lanes: fast, slow)", prompt) + self.assertIn(f"append one line per task to {OUT}", prompt) + self.assertIn("/projects/public/workflow/docs/orchestrator.md", prompt.replace(str(HERE), "/projects/public/workflow")) + self.assertIn("foreground", prompt) + self.assertNotIn("{", prompt) + + def test_dry_run_writes_no_summary(self): + self.batch("2", "--dry-run") + self.assertFalse((self.box.env.root() / OUT).exists()) + + def test_existing_summary_not_overwritten(self): + f = self.box.env.root() / OUT + f.parent.mkdir(exist_ok=True) + f.write_text("live\n") + self.batch("2") + self.assertTrue(f.read_text().startswith("live\n# wf-batch")) + + def test_size_lane(self): + code, out, err = self.batch("2", "--lanes", "fast", "--dry-run") + self.assertEqual((code, err), (0, "")) + self.assertIn("lanes: fast", out) + + def test_default_lanes(self): + self.batch("3") + self.assertIn("for up to 3 tasks (lanes: every lane with ready tasks, see wf lanes)", self.unit_argv()[-1]) + + def test_for_sets_ceiling_and_model(self): + self.batch("1", "--for", "2h", "--model", "sonnet", "--mem", "2G") + argv = self.unit_argv() + self.assertIn("--setenv=CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=7200000", argv) + self.assertEqual(argv[argv.index("--model") + 1], "sonnet") + self.assertIn(f"MemoryMax={2 * 1024 ** 3}", argv) + + def test_no_claude(self): + self.box.caller["PATH"] = "/nonexistent" + code, out, err = self.batch("2") + self.assertEqual((code, out, err), (1, "", "wf: claude not found on PATH\n")) + self.assertEqual(self.box.fake.ran("systemd-run"), []) + + def test_bad_count(self): + code, _, err = self.batch("0") + self.assertEqual(code, 2) + self.assertIn("N must be ≥ 1", err) + + def test_summary_header_written(self): + code, out, err = self.batch("2", "--lanes", "fast") + self.assertEqual(code, 0) + text = (self.box.env.root() / OUT).read_text() + self.assertEqual(text, "# wf-batch proj 2026-10-01 14:00 (N=2, lanes: fast)\n") + + def test_dry_run(self): + code, out, err = self.batch("2", "--dry-run") + self.assertEqual((code, err), (0, "")) + self.assertIn("CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=21600000 wf res run --mem 4G --for 6h --title wf-batch -- ", out) + self.assertIn(f"{self.claude} -p --model opus", out) + self.assertEqual(self.box.fake.ran("systemd-run"), []) + + def test_mem_scales_with_n(self): + for args, mem in ((("4",), "6G"), (("1",), "4G"), (("9",), "8G"), (("4", "--mem", "3G"), "3G")): + code, out, err = self.batch(*args, "--dry-run") + self.assertIn(f"wf res run --mem {mem} --for", out, args) + + def test_defaults_from_history(self): + # peaks 2,3,4 → p95 4 ×1.15 = 4.6 GB; durations 20,30,40 → p90 40 ×1.5 = 60 min; ceiling stays 6h + seed_history(self.box, [hist_rec(f"r-{i}", title="wf-batch", peak=p, minutes=m) + for i, (p, m) in enumerate(((2.0, 20.0), (3.0, 30.0), (4.0, 40.0)))]) + code, out, err = self.batch("4", "--dry-run") + self.assertEqual((code, err), (0, "")) + self.assertIn("CEILING_MS=21600000 wf res run --mem 4.6G --for 60m --title wf-batch -- ", out) + out = self.batch("4", "--mem", "3G", "--for", "2h", "--dry-run")[1] + self.assertIn("CEILING_MS=7200000 wf res run --mem 3G --for 2h --title wf-batch -- ", out) + + def test_busy_exit_3(self): + self.box.available_gb = 1 + code, out, _ = self.batch("2") + self.assertEqual(code, 3) + self.assertIn("busy", out) + + def test_force_past_unused_claim(self): + self.box.wf("run", "--mem", "10G", "--for", "40m", "--title", "big", "--", "x") + code, out, _ = self.batch("2") + self.assertEqual(code, 3) + code, out, err = self.batch("2", "--force") + self.assertEqual(code, 0, (out, err)) + self.assertIn("r-2 started", out) + + +class Status(BatchBase): + def test_none(self): + code, out, err = self.batch("--status") + self.assertEqual((code, out, err), (0, "no batch in this project\n", "")) + + def test_running_and_summary(self): + self.batch("2") + md = self.box.tmp / "proj" / OUT + with md.open("a") as f: + f.write("t-a sonnet done abc123\n") + (md.parent / "wf-batch-2026-09-30-0100.md").write_text("old\n") + code, out, _ = self.batch("--status") + self.assertEqual(code, 0) + lines = out.splitlines() + self.assertTrue(lines[0].startswith('r-1 proj "wf-batch" running'), lines[0]) + self.assertIn(f"log {self.box.env.state / 'logs' / 'r-1.log'}", lines[1]) + self.assertEqual(lines[2:], [f"{OUT}:", "# wf-batch proj 2026-10-01 14:00 (N=2, lanes: every lane with ready tasks, see wf lanes)", + "t-a sonnet done abc123"]) + + def test_orch_log_progress(self): + self.batch("2") + out_dir = self.box.tmp / "proj" / "out" + (out_dir / "wf-orch.log").write_text( + "2026-10-01 13:00 fast sonnet t-old done aaa\n2026-10-01 14:05 fast sonnet t-new done bbb\n") + _, out, _ = self.batch("--status") + self.assertIn("finished since batch start", out) + self.assertIn("t-new done bbb", out) + self.assertNotIn("t-old", out) + + def test_stop_creates_file_and_status_reports(self): + code, out, err = self.batch("--stop") + self.assertEqual((code, err), (0, "")) + f = self.box.tmp / "proj" / "out" / "wf-batch.stop" + self.assertTrue(f.exists()) + self.assertIn("out/wf-batch.stop", out) + code, out, _ = self.batch("--status") + self.assertIn("stop requested: out/wf-batch.stop", out) + + def test_stale_stop_cleared_on_start(self): + f = self.box.tmp / "proj" / "out" / "wf-batch.stop" + f.parent.mkdir(exist_ok=True) + f.touch() + code, out, err = self.batch("2") + self.assertEqual(code, 0) + self.assertIn("stale out/wf-batch.stop cleared", err) + + def test_stop_consumed_on_exit(self): + f = self.box.tmp / "proj" / "out" / "wf-batch.stop" + f.parent.mkdir(exist_ok=True) + orig = wf_res.cmd_run + + def fake(env, cfg, args): + f.touch() + return 0 + wf_res.cmd_run = fake + try: + code, _, _ = self.batch("2") + finally: + wf_res.cmd_run = orig + self.assertEqual(code, 0) + self.assertFalse(f.exists()) + + def test_prompt_has_stop_check(self): + code, out, _ = self.batch("2", "--dry-run") + self.assertIn("out/wf-batch.stop", out) + self.assertIn("stopped: stop file", out) + + def test_prompt_cloud_lane(self): + code, out, _ = self.batch("2", "--dry-run") + self.assertIn("wf orch pick cloud", out) + self.assertIn("never run `wf cloud pull`", out) + self.assertIn("sidecar pulls every 10 min", out) + self.assertIn(f"-- {self.claude} -p", out) # not a cloud project: plain claude -p job + + def test_prompt_stale_branch_once(self): + code, out, _ = self.batch("2", "--dry-run") + self.assertIn("pre-existing branch", out) + self.assertIn("ONCE", out) + + def test_prompt_checks_stop_before_every_spawn(self): + code, out, _ = self.batch("2", "--dry-run") + self.assertIn("Before EVERY spawn", out) + self.assertIn(f"test -e {self.box.tmp / 'proj' / 'out' / 'wf-batch.stop'}", out) + self.assertNotIn("Each round: first", out) + code, out, _ = self.batch("--stop") + self.assertIn("before its next spawn", out) + + def test_n_required_without_status(self): + code, _, err = self.batch() + self.assertEqual(code, 2) + + +PREP_TASKS = """\ +## Awaiting your decision + +## Pending + +- **t-ready** [P1] (<1h): Ready. + Done: works. + +- **t-low** [P3] (<1h): Low, no Done. + +- **t-hi** [P1] (1h): High, no Done. + +- **t-mid** [P2] (1h): Mid, no Done. + +## Needs human + +## Deferred +""" + + +class Prep(BatchBase): + def setUp(self): + super().setUp() + root = self.box.env.root() + (root / "workflow.toml").write_text('format = 1\ntasks = "TASKS.md"\narchive = "archive.md"\n') + (root / "TASKS.md").write_text(PREP_TASKS) + (root / "archive.md").write_text("# Archive\n") + + def test_prompt_lists_targets(self): + code, out, err = self.batch("2", "--prep") + self.assertEqual((code, err), (0, "")) + prompt = self.unit_argv()[-1] + self.assertIn("Tasks: t-hi t-mid.", prompt) + self.assertIn("never implement", prompt) + self.assertIn("wf set <id> --done", prompt) + self.assertIn("wf add -s awaiting", prompt) + self.assertIn(f"to {OUT}", prompt) + for ph in ("never invert recorded original behaviour", "wf add --parent <id> slices", + "concrete files/runs", "[opus: <why>]", "[Model: <m>]"): + self.assertIn(ph, prompt) + self.assertNotIn("{", prompt) + self.assertEqual((self.box.env.root() / OUT).read_text(), + "# wf-batch proj 2026-10-01 14:00 (prep K=2: t-hi t-mid)\n") + self.assertEqual(self.box.ledger().get("r-1").title, "wf-batch") + + def test_lanes_filter(self): + self.batch("5", "--prep", "--lanes", "fast") + self.assertIn("Tasks: t-low.", self.unit_argv()[-1]) + + def test_prep_alias_all(self): + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + code = wf_res.prep_main(["all"], self.box.env) + self.assertEqual((code, err.getvalue()), (0, "")) + self.assertIn("Tasks: t-hi t-mid", self.unit_argv()[-1]) + + def test_nothing_to_prep_starts_nothing(self): + code, out, err = self.batch("3", "--prep", "--lanes", "nolane") + self.assertEqual((code, out, err), (0, "prep: no pending task without Done (lanes: nolane); nothing started\n", "")) + self.assertEqual(self.box.fake.ran("systemd-run"), []) + self.assertFalse((self.box.env.root() / OUT).exists()) + + def test_prep_mem_default_small(self): + for args, mem in ((("3", "--prep"), "1G"), (("3", "--prep", "--mem", "2G"), "2G")): + code, out, err = self.batch(*args, "--dry-run") + self.assertIn(f"wf res run --mem {mem} --for", out, args) + + def test_dry_run(self): + code, out, err = self.batch("1", "--prep", "--dry-run") + self.assertEqual((code, err), (0, "")) + self.assertIn("--title wf-batch -- ", out) + self.assertIn("t-hi", out) + self.assertEqual(self.box.fake.ran("systemd-run"), []) + + def test_no_project(self): + (self.box.env.root() / "workflow.toml").unlink() + code, out, err = self.batch("1", "--prep") + self.assertEqual(code, 1) + self.assertTrue(err.startswith("wf: "), err) + + +class Dispatch(unittest.TestCase): + def test_wf_forwards_batch(self): + r = subprocess.run([sys.executable, str(HERE / "wf.py"), "batch", "-h"], capture_output=True, text=True) + self.assertEqual(r.returncode, 0) + self.assertIn("usage: wf batch", r.stdout) + + def test_listed_in_wf_help(self): + r = subprocess.run([sys.executable, str(HERE / "wf.py"), "-h"], capture_output=True, text=True) + self.assertIn("batch", r.stdout) + + +if __name__ == "__main__": + unittest.main() + + +FAKE_WF = """import sys, pathlib +d = pathlib.Path(sys.argv[0]).parent +with open(d / "calls.log", "a") as f: + f.write(" ".join(sys.argv[1:]) + "\\n") +if sys.argv[1:3] == ["cloud", "pull"] and (d / "clear").exists(): + for rec in (d / ".wf" / "cloud").glob("*.json"): + rec.unlink() +if sys.argv[1:3] == ["cloud", "pull"] and not (d / "pulled").exists(): + (d / "pulled").touch() + print("t-a: done (session_1, $0.40 usage): merged abc1234") + print("archive session_1 failed: x; archive by hand (wf cloud archive --ended)") + print("report: commit abc1234") + print("t-b: running (session_2, 12 min)") + print("t-c: handback (session_3, $0.10 usage): cloud handback: stuck") + print("t-d: pulled by another wf cloud pull, skipped") + sys.exit(1) +""" + + +class Sidecar(unittest.TestCase): + """wf batch on a cloud = true project: the job runs the orchestrator under the pull sidecar.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name) + (self.root / "out").mkdir() + self.summary = self.root / OUT + self.summary.write_text("# wf-batch\n") + self.fake = self.root / "fakewf.py" + self.fake.write_text(FAKE_WF) + + def tearDown(self): + self._tmp.cleanup() + + def run_sidecar(self, child_s, every=0.2, rc=7, **kw): + child = [sys.executable, "-c", f"import time, sys; time.sleep({child_s}); sys.exit({rc})"] + out = io.StringIO() + with redirect_stdout(out): + code = wf_res.sidecar(child, self.root, self.summary, every, [sys.executable, str(self.fake)], **kw) + return code, out.getvalue() + + def records(self, *ids): + d = self.root / ".wf" / "cloud" + d.mkdir(parents=True) + for i in ids: + (d / f"{i}.json").write_text("{}") + + def tail_line(self): + return [ln[6:] for ln in self.summary.read_text().splitlines()[1:] if "tail" in ln] + + def test_tail_ends_on_empty_records(self): + self.records("t-b") + (self.root / "clear").touch() + code, out = self.run_sidecar(0.05, every=0.1) + self.assertEqual(code, 7) + self.assertEqual(len([c for c in self.calls() if c.startswith("cloud pull")]), 1) + self.assertIn("orch post t-a cloud --result done --no-pick --commit abc1234", self.calls()) + self.assertEqual(self.tail_line(), ["cloud tail ended: no cloud records left; 1 pulls; left: none (sidecar)"]) + + def test_tail_ends_on_stop_file(self): + self.records("t-b", "t-e") + threading.Timer(0.5, (self.root / wf_res.STOP_FILE).touch).start() + code, _ = self.run_sidecar(0.05, every=0.2) + pulls = [c for c in self.calls() if c.startswith("cloud pull")] + self.assertGreaterEqual(len(pulls), 1) # pulled in the tail until the stop file + self.assertEqual(self.tail_line(), [f"cloud tail ended: stop file; {len(pulls)} pulls; left: t-b t-e (sidecar)"]) + self.assertEqual(code, 7) + + def test_tail_ends_on_cap(self): + self.records("t-b") + shrunk = [] + code, out = self.run_sidecar(0.05, every=0.1, cap=0.35, shrink=lambda: shrunk.append(1) or "shrunk") + self.assertEqual((code, shrunk), (7, [1])) # reservation shrunk once, entering the tail + self.assertIn("shrunk", out) + pulls = [c for c in self.calls() if c.startswith("cloud pull")] + self.assertGreaterEqual(len(pulls), 2) + self.assertTrue(all(c.startswith("cloud pull") or c.startswith("orch post") for c in self.calls())) # no picks + self.assertEqual(self.tail_line(), + [f"cloud tail ended: {0.35 / 3600:g}h cap; {len(pulls)} pulls; left: t-b (sidecar)"]) + + def test_no_tail_without_records(self): + shrunk = [] + code, _ = self.run_sidecar(0.05, every=0.1, shrink=lambda: shrunk.append(1) or "") + self.assertEqual((code, shrunk, self.calls(), self.tail_line()), (7, [], [], [])) + + def test_shrink_reservation(self): + b = BatchBase("run") + b.setUp() + try: + b.batch("4", "--mem", "6G") + msg = wf_res.shrink_reservation(b.box.env, "r-1") + e = b.box.ledger().get("r-1") + self.assertEqual((e.mem_gb, e.cpus, e.state), (0.2, 1, "running")) + self.assertIn("r-1 reservation 6", msg) + self.assertIn("not running", wf_res.shrink_reservation(b.box.env, "r-9")) + finally: + b.tearDown() + + def calls(self): + f = self.root / "calls.log" + return f.read_text().splitlines() if f.exists() else [] + + def test_pulls_and_posts_while_child_lives(self): + code, out = self.run_sidecar(1.0) + self.assertEqual(code, 7) # the orchestrator's exit code + calls = self.calls() + pulls = [c for c in calls if c.startswith("cloud pull")] + self.assertGreaterEqual(len(pulls), 2) # repeats every interval while the child lives + self.assertEqual(pulls[0], f"cloud pull --all --project {self.root}") + self.assertEqual([c for c in calls if c.startswith("orch post")], + ["orch post t-a cloud --result done --no-pick --commit abc1234", + "orch post t-c cloud --result handback --no-pick"]) + lines = self.summary.read_text().splitlines()[1:] + self.assertEqual([ln[6:] for ln in lines], ["cloud t-a done abc1234 (sidecar pull)", + "cloud t-c handback - (sidecar pull)"]) + self.assertIn("t-a: done", out) # pull output lands in the job log + + def test_stops_with_child(self): + code, _ = self.run_sidecar(0.1, every=5, rc=0) + self.assertEqual((code, self.calls()), (0, [])) + + def test_stop_file_ends_pulls(self): + (self.root / wf_res.STOP_FILE).touch() + code, out = self.run_sidecar(0.7) + self.assertEqual((code, self.calls()), (7, [])) + self.assertIn("sidecar: stop file, no more pulls", out) + + def test_cloud_project_job_wraps_claude(self): + b = BatchBase("run") + b.setUp() + try: + root = b.box.env.root() + (root / "workflow.toml").write_text('format = 1\ntasks = "TASKS.md"\narchive = "a.md"\ncloud = true\n') + code, out, _ = b.batch("2", "--dry-run") + self.assertEqual(code, 0) + argv = shlex.split(out.split(" -- ", 1)[1]) + self.assertEqual(argv[:9], [sys.executable, str(HERE / "wf_res.py"), "batch-sidecar", "--root", str(root), + "--summary", OUT, "--every", "600"]) + self.assertEqual(argv[9:12], ["--", str(b.claude), "-p"]) + finally: + b.tearDown() + + def test_main_entry(self): + r = subprocess.run([sys.executable, str(HERE / "wf_res.py"), "batch-sidecar", "--root", str(self.root), + "--summary", OUT, "--every", "5", "--wf", f"{sys.executable} {self.fake}", "--", + sys.executable, "-c", "import sys; sys.exit(3)"], capture_output=True, text=True) + self.assertEqual((r.returncode, r.stderr), (3, "")) + + +LOG_ROWS = "".join(f"2026-10-01T1{i}:00:00 slow opus t-{i} done abc {m}m00s\n" for i, m in enumerate((20, 25, 31))) + + +class Fit(BatchBase): + def log(self, text): + (self.box.env.root() / "out").mkdir(exist_ok=True) + (self.box.env.root() / "out" / "wf-orch.log").write_text(text) + + def test_left_shrinks_k_and_sets_for(self): + self.log(LOG_ROWS) # p90 of 20,25,31 = 31 min + code, out, err = self.batch("4", "--left", "1h40m", "--dry-run") + self.assertEqual((code, err), (0, "")) + self.assertIn("fit: 3 of 4 tasks (left 1h40m, task p90 31m from 3 runs)\n", out) + self.assertIn("CEILING_MS=6000000 wf res run --mem 4.5G --for 1h40m --title wf-batch -- ", out) + self.assertIn("for up to 3 tasks", out) + + def test_left_default_30m(self): + code, out, err = self.batch("4", "--left", "45m", "--dry-run") + self.assertIn("fit: 1 of 4 tasks (left 45m, task p90 30m default (0 runs))\n", out) + self.assertIn("--for 45m", out) + + def test_left_too_short_starts_nothing(self): + self.log(LOG_ROWS) + code, out, err = self.batch("4", "--left", "30m") + self.assertEqual((code, err), (0, "")) + self.assertEqual(out, "fit: 0 of 4 tasks (left 30m, task p90 31m from 3 runs); nothing started\n") + self.assertEqual(self.box.fake.ran("systemd-run"), []) + + def test_prompt_has_deadline_check(self): + code, out, _ = self.batch("2", "--for", "2h", "--dry-run") + self.assertIn("wf batch --time-left 2026-10-01T16:00+00:00", out) + self.assertIn("stopped: deadline", out) + + def test_time_left(self): + self.log(LOG_ROWS) + code, out, err = self.batch("--time-left", "2026-10-01T15:00+00:00") + self.assertEqual((code, out, err), (0, "time left 1h, task p90 31m (3 runs): spawn\n", "")) + code, out, err = self.batch("--time-left", "2026-10-01T14:30+00:00") + self.assertEqual((code, out), (0, "time left 30m, task p90 31m (3 runs): stop\n")) + code, out, err = self.batch("--time-left", "2026-10-01T13:00+00:00") + self.assertEqual((code, out), (0, "time left 0m, task p90 31m (3 runs): stop\n")) + + def test_time_left_bad(self): + code, out, err = self.batch("--time-left", "soon") + self.assertEqual(code, 1) + self.assertIn("wf: --time-left", err) |
