merge: worktree-agent-ad389a7af758a1e42 (mplex final)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,14 @@ if (opts.mplex.has_value()) {
|
||||
like the Python (`sessions should be nested with care, unset TAI_PLEXER_ACTIVE to force`,
|
||||
`open terminal failed: not a terminal`, `no server running on <sock>`, ...).
|
||||
|
||||
Argument quirk to keep in step with tai.py: argparse's `nargs=REMAINDER` swallows a literal `--`
|
||||
inside the mplex words (tai.py: `--mplex send-keys -X -- cursor-up` loses the `--`), while
|
||||
`tests/mplex_cli_main.cpp` passes argv through untouched. The `src/cli` parser must drop/keep `--`
|
||||
exactly as `argparse.REMAINDER` does (verify against `python3 tai.py --mplex ls -- x`).
|
||||
|
||||
The `version` format variable is `detz::kVersion` when the build puts the generated `version.hpp`
|
||||
on the include path (the Makefile's `-I$(GEN_INC)` does), `1.44.2` otherwise.
|
||||
|
||||
## `--plexer-wrap`
|
||||
|
||||
tai.py 40129-40133: same `home` computation, `profile = args.profile or "default"`:
|
||||
@@ -79,6 +87,6 @@ behave as in tai.py.
|
||||
## Verified
|
||||
|
||||
See the report of the porting run: `tests/mplex_flow_test.cpp` (spec/flow corpus against tai.py),
|
||||
`tests/mplex_e2e.py` (six scenarios run through a pty against both `tai.py --mplex` and
|
||||
`tests/mplex_e2e.py` (seven scenarios run through a pty against both `tai.py --mplex` and
|
||||
`build/mplex_cli --mplex`, observations compared), `tests/mplex_tmux_oracle.py` (tmux 3.6 oracle for
|
||||
the tmux controller).
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
"""Differential executor: runs every task of mplex_matrix_gen.py on tai.py --mplex and on the C++
|
||||
binary, each in its own throwaway TAI_HOME, and compares step outputs and full state dumps.
|
||||
|
||||
usage: python3 -I mplex_matrix_exec.py <repo root> <cpp binary> <tasks.json> <out dir> [--jobs N] [--filter TEXT]
|
||||
|
||||
Normalized (only these): the TAI_HOME and work directory paths (replaced by <H> and <W>), times of the
|
||||
forms "Mon Jan 1 12:00:00 2026" and "12:00 01-Jan-26" (tmux default formats), and the minute digits of the
|
||||
clock mode screen. Everything else must be identical.
|
||||
"""
|
||||
import concurrent.futures
|
||||
import difflib
|
||||
import fcntl
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import re
|
||||
import select
|
||||
import shutil
|
||||
import signal
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import termios
|
||||
import threading
|
||||
import time
|
||||
|
||||
ROOT, CPP, TASKS, OUT = [os.path.abspath(p) for p in sys.argv[1:5]]
|
||||
ARGS = sys.argv[5:]
|
||||
JOBS = int(ARGS[ARGS.index("--jobs") + 1]) if "--jobs" in ARGS else 8
|
||||
FILTER = ARGS[ARGS.index("--filter") + 1] if "--filter" in ARGS else ""
|
||||
|
||||
spec = importlib.util.spec_from_file_location("tai_mod", ROOT + "/tai.py")
|
||||
tai = importlib.util.module_from_spec(spec)
|
||||
sys.modules["tai_mod"] = tai
|
||||
spec.loader.exec_module(tai)
|
||||
|
||||
CWD = "/tmp/mxcwd"
|
||||
os.makedirs(CWD, exist_ok=True)
|
||||
TIME_RES = [re.compile(r"[A-Z][a-z]{2} [A-Z][a-z]{2} [ \d]\d \d\d:\d\d:\d\d \d{4}"), re.compile(r"\d\d:\d\d \d\d-[A-Z][a-z]{2}-\d\d"), re.compile(r"\d\d:\d\d:\d\d"), re.compile(r"/dev/pts/\d+")]
|
||||
|
||||
BASE_CONFIG = '[options]\nautomatic-rename = false\nstatus-right = "R"\ndefault-command = "exec bash --norc --noprofile"\n'
|
||||
P5_CONFIG = BASE_CONFIG + 'base-index = 1\npane-base-index = 1\nmode-keys = "vi"\nmain-pane-width = 40\nmain-pane-height = 12\n'
|
||||
|
||||
PRESETS = {
|
||||
"P0": [["new-session", "-d", "-s", "main", "-x", "80", "-y", "24"]],
|
||||
}
|
||||
P0 = PRESETS["P0"]
|
||||
P1 = P0 + [["new-session", "-d", "-s", "other", "-x", "80", "-y", "24"], ["new-window", "-t", "other"]]
|
||||
P2 = P0 + [["new-window"], ["split-window", "-h"], ["new-window"], ["split-window", "-v"], ["split-window", "-h"], ["split-window", "-v"],
|
||||
["split-window", "-h"], ["select-layout", "tiled"], ["select-window", "-t", "main:0"]]
|
||||
P3 = P2 + [["select-window", "-t", "main:2"], ["resize-pane", "-Z"], ["select-pane", "-t", "main:1.0"], ["select-pane", "-m"],
|
||||
["set-option", "-g", "status-position", "top"]]
|
||||
P4 = P2 + [["set-buffer", "-b", "b1", "hello"], ["set-buffer", "world"], ["set-option", "-g", "status-position", "top"],
|
||||
["select-window", "-t", "main:1"], ["send-keys", "-t", "main:1.0", "seq 1 100", "Enter"]]
|
||||
P5 = P0 + [["new-window"], ["split-window", "-h"], ["new-window"], ["split-window", "-v"], ["split-window", "-h"], ["select-layout", "main-vertical"],
|
||||
["select-window", "-t", "main:1"], ["select-layout", "even-vertical"], ["set-buffer", "-b", "b1", "hello"],
|
||||
["send-keys", "-t", "main:1.1", "seq 1 100", "Enter"]]
|
||||
PRESET_STEPS = {"P0": P0, "P1": P1, "P2": P2, "P3": P3, "P4": P4, "P5": P5, "P4c": P4 + [["@sleep", "0.8"], ["copy-mode"]], "P5c": P5 + [["@sleep", "0.8"], ["copy-mode"]]}
|
||||
PRESET_CONFIG = {"P5": P5_CONFIG, "P5c": P5_CONFIG}
|
||||
|
||||
LIST_FORMAT = ("#{session_name}:#{window_index}.#{pane_index} #{pane_id} #{window_id} #{pane_left},#{pane_top} #{pane_width}x#{pane_height} "
|
||||
"a=#{pane_active} l=#{pane_last} m=#{pane_marked} z=#{window_zoomed_flag} mode=#{pane_mode} hist=#{history_size} dead=#{pane_dead} "
|
||||
"sp=#{scroll_position} cc=#{copy_cursor_x},#{copy_cursor_y} sel=#{selection_present} s=#{pane_search_string} "
|
||||
"title=#{pane_title} syn=#{pane_synchronized} cmd=#{pane_current_command}")
|
||||
WIN_FORMAT = ("#{session_name}:#{window_index} #{window_id} #{window_name} #{window_flags} #{window_panes} #{window_width}x#{window_height} "
|
||||
"act=#{window_active} last=#{window_last_flag} z=#{window_zoomed_flag} mk=#{window_marked_flag}")
|
||||
SES_FORMAT = "#{session_name} #{session_id} w=#{session_windows} att=#{session_attached}"
|
||||
MARK = ["display-message", "-p", "@@ %s"]
|
||||
|
||||
|
||||
class Impl:
|
||||
def __init__(self, name, argv, config):
|
||||
self.name = name
|
||||
self.argv = argv
|
||||
self.tmp = tempfile.mkdtemp(prefix="mx", dir="/tmp")
|
||||
self.home = os.path.join(self.tmp, "h")
|
||||
self.work = os.path.join(self.tmp, "w")
|
||||
os.makedirs(os.path.join(self.home))
|
||||
os.makedirs(self.work)
|
||||
with open(os.path.join(self.home, "mplex.toml"), "w") as handle:
|
||||
handle.write(config)
|
||||
with open(os.path.join(self.work, "src.conf"), "w") as handle:
|
||||
handle.write("set -g status-left X\nbogus-command\nset -g status-right Y\n")
|
||||
with open(os.path.join(self.work, "buf.txt"), "w") as handle:
|
||||
handle.write("from file\n")
|
||||
self.env = dict(os.environ)
|
||||
for name_ in ("TAI_PLEXER_ACTIVE", "TAI_PLEXER_SOCKET", "TAI_PLEXER_PANE", "TMUX", "VISUAL", "EDITOR"):
|
||||
self.env.pop(name_, None)
|
||||
self.env.update({"TAI_HOME": self.home, "HOME": self.work, "SHELL": "/bin/bash", "TERM": "xterm-256color", "TAI_PASSPHRASE": "",
|
||||
"LANG": "C.UTF-8", "PS1": "$ "})
|
||||
|
||||
def norm(self, text):
|
||||
text = text.replace(self.home, "<H>").replace(self.work, "<W>").replace(self.tmp, "<T>")
|
||||
for regex in TIME_RES:
|
||||
text = regex.sub("<TIME>", text)
|
||||
return text
|
||||
|
||||
def sub(self, words):
|
||||
return [w.replace("{W}", self.work) for w in words]
|
||||
|
||||
def cli(self, *commands):
|
||||
"""commands: lists of words; chained with ';' like the command line does."""
|
||||
words = []
|
||||
for index, command in enumerate(commands):
|
||||
if index:
|
||||
words.append(";")
|
||||
words.extend(self.sub(command))
|
||||
try:
|
||||
done = subprocess.run(self.argv + words, env=self.env, cwd=CWD, stdin=subprocess.DEVNULL, capture_output=True, text=True,
|
||||
errors="replace", timeout=30)
|
||||
except subprocess.TimeoutExpired:
|
||||
return 124, "TIMEOUT"
|
||||
return done.returncode, self.norm(done.stdout + "||" + done.stderr)
|
||||
|
||||
STATE_COMMANDS = [["list-sessions", "-F", SES_FORMAT], MARK[:2] + ["@@ 1"], ["list-windows", "-a", "-F", WIN_FORMAT], MARK[:2] + ["@@ 2"],
|
||||
["list-panes", "-a", "-F", LIST_FORMAT], MARK[:2] + ["@@ 3"], ["show-options", "-g"], MARK[:2] + ["@@ 4"], ["show-options"],
|
||||
MARK[:2] + ["@@ 5"], ["show-options", "-w"], MARK[:2] + ["@@ 6"], ["list-buffers"], MARK[:2] + ["@@ 7"], ["show-messages"],
|
||||
MARK[:2] + ["@@ 8"], ["list-keys", "-T", "prefix"], MARK[:2] + ["@@ 9"], ["list-keys", "-T", "root"],
|
||||
MARK[:2] + ["@@ 10"], ["list-clients", "-F", "#{client_session} #{client_width}x#{client_height} #{client_key_table}"]]
|
||||
|
||||
def dump(self):
|
||||
"""Poll until every pane shows a prompt, then return captures plus the full state listing (two calls)."""
|
||||
out = ""
|
||||
for _ in range(40):
|
||||
rc, ids = self.cli(["list-panes", "-a", "-F", "#{pane_id}"])
|
||||
if rc != 0:
|
||||
return "NOSERVER " + ids
|
||||
panes = ids.split("||")[0].split()
|
||||
commands = []
|
||||
for pane in panes:
|
||||
commands.append(["capture-pane", "-p", "-t", pane])
|
||||
commands.append(["display-message", "-p", "@@ end"])
|
||||
commands.append(["display-message", "-p", "@@ state"])
|
||||
rc, out = self.cli(*(commands + self.STATE_COMMANDS))
|
||||
head = out.split("@@ state")[0]
|
||||
parts = head.split("@@ end")
|
||||
if not panes or all(("$" in part or "bash" in part) for part in parts[:-1]):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
return out
|
||||
|
||||
def build(self, preset):
|
||||
steps = PRESET_STEPS[preset]
|
||||
for step in steps:
|
||||
if step[0] == "@sleep":
|
||||
time.sleep(float(step[1]))
|
||||
continue
|
||||
rc, out = self.cli(step)
|
||||
if rc != 0:
|
||||
return "BUILD FAIL %d %s %s" % (rc, step, out)
|
||||
if preset in ("P4", "P4c", "P5", "P5c"):
|
||||
time.sleep(0.4)
|
||||
return ""
|
||||
|
||||
def kill(self):
|
||||
try:
|
||||
self.cli(["kill-server"])
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.1)
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def delta(a, b):
|
||||
if isinstance(a, tuple) or isinstance(b, tuple):
|
||||
a, b = repr(a), repr(b)
|
||||
return "\n".join(list(difflib.unified_diff(str(a).split("\n"), str(b).split("\n"), "py", "cpp", lineterm="", n=1))[:60])
|
||||
|
||||
|
||||
def make_impls(preset):
|
||||
config = PRESET_CONFIG.get(preset, BASE_CONFIG)
|
||||
py = Impl("py", [sys.executable, "-I", ROOT + "/tai.py", "--mplex"], config)
|
||||
cpp = Impl("cpp", [CPP, "--mplex"], config)
|
||||
return py, cpp
|
||||
|
||||
|
||||
def both(fn_py, fn_cpp):
|
||||
results = [None, None]
|
||||
|
||||
def run(index, fn):
|
||||
try:
|
||||
results[index] = fn()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
results[index] = "EXC %r" % (exc,)
|
||||
|
||||
threads = [threading.Thread(target=run, args=(0, fn_py)), threading.Thread(target=run, args=(1, fn_cpp))]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
return results
|
||||
|
||||
|
||||
def run_cmd_task(task):
|
||||
py, cpp = make_impls(task["preset"])
|
||||
log = []
|
||||
try:
|
||||
fails = both(lambda: py.build(task["preset"]), lambda: cpp.build(task["preset"]))
|
||||
if fails[0] != fails[1] or fails[0]:
|
||||
log.append({"step": "build", "diff": delta(fails[0], fails[1])})
|
||||
return log
|
||||
# state before any step
|
||||
a, b = both(py.dump, cpp.dump)
|
||||
if a != b:
|
||||
log.append({"step": "initial dump", "diff": delta(a, b)})
|
||||
return log
|
||||
for step in task["steps"]:
|
||||
ra, rb = both(lambda: py.cli(step), lambda: cpp.cli(step))
|
||||
if ra != rb:
|
||||
log.append({"step": step, "kind": "output", "diff": delta(ra, rb)})
|
||||
a, b = both(py.dump, cpp.dump)
|
||||
if a != b:
|
||||
log.append({"step": step, "kind": "state", "diff": delta(a, b)})
|
||||
# a diverged state would cascade: stop this chunk
|
||||
break
|
||||
finally:
|
||||
both(py.kill, cpp.kill)
|
||||
return log
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, impl, words):
|
||||
self.impl = impl
|
||||
self.screen = tai.TuiVtScreen(24, 80)
|
||||
pid, fd = pty.fork()
|
||||
if pid == 0:
|
||||
os.chdir(CWD)
|
||||
os.execvpe(impl.argv[0], impl.argv + words, impl.env)
|
||||
self.pid, self.fd = pid, fd
|
||||
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0))
|
||||
self.settle(1.0)
|
||||
|
||||
def settle(self, quiet=0.3, limit=3.0):
|
||||
end, last = time.time() + limit, time.time()
|
||||
while time.time() < end and time.time() - last < quiet:
|
||||
r, _, _ = select.select([self.fd], [], [], 0.05)
|
||||
if r:
|
||||
try:
|
||||
data = os.read(self.fd, 65536)
|
||||
except OSError:
|
||||
return
|
||||
if not data:
|
||||
return
|
||||
self.screen.feed(data)
|
||||
last = time.time()
|
||||
|
||||
def send(self, data):
|
||||
try:
|
||||
os.write(self.fd, data)
|
||||
except OSError:
|
||||
pass
|
||||
self.settle()
|
||||
|
||||
def text(self):
|
||||
text = self.screen.text()
|
||||
text = re.sub(r"/dev/pts/\d+", "/dev/pts/N", text).replace("^[", "")
|
||||
text = "\n".join(line.rstrip() for line in text.split("\n")).rstrip("\n")
|
||||
return re.sub(r"\d", "#", text) if "clock" in text.lower() else text
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
os.kill(self.pid, signal.SIGKILL)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.waitpid(self.pid, 0)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.close(self.fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def key_bytes(name):
|
||||
return tai.MplexKeys.to_bytes(name)
|
||||
|
||||
|
||||
def run_key_task(task):
|
||||
py, cpp = make_impls(task["preset"])
|
||||
log = []
|
||||
clients = []
|
||||
try:
|
||||
fails = both(lambda: py.build(task["preset"]), lambda: cpp.build(task["preset"]))
|
||||
if fails[0] != fails[1] or fails[0]:
|
||||
return [{"step": "build", "diff": delta(fails[0], fails[1])}]
|
||||
for impl in (py, cpp):
|
||||
clients.append(Client(impl, ["attach-session", "-t", "main"]))
|
||||
# copy-mode tables are entered by the preset (P4c/P5c); drop the attach screen noise first
|
||||
data = key_bytes(task["key"])
|
||||
if task["table"] == "prefix":
|
||||
data = b"\x02" + data
|
||||
for client in clients:
|
||||
client.send(data)
|
||||
time.sleep(0.8)
|
||||
for client in clients:
|
||||
client.settle(0.2)
|
||||
screens = [c.text() for c in clients]
|
||||
a, b = both(py.dump, cpp.dump)
|
||||
if screens[0] != screens[1]:
|
||||
log.append({"step": task["key"], "kind": "screen", "diff": delta(screens[0], screens[1])})
|
||||
if a != b:
|
||||
log.append({"step": task["key"], "kind": "state", "diff": delta(a, b)})
|
||||
# second key: dismiss anything open, then compare again
|
||||
for client in clients:
|
||||
client.send(b"\x1b")
|
||||
time.sleep(0.8)
|
||||
for client in clients:
|
||||
client.settle(0.2)
|
||||
screens = [c.text() for c in clients]
|
||||
a, b = both(py.dump, cpp.dump)
|
||||
if screens[0] != screens[1]:
|
||||
log.append({"step": task["key"] + " +Esc", "kind": "screen", "diff": delta(screens[0], screens[1])})
|
||||
if a != b:
|
||||
log.append({"step": task["key"] + " +Esc", "kind": "state", "diff": delta(a, b)})
|
||||
finally:
|
||||
for client in clients:
|
||||
client.close()
|
||||
both(py.kill, cpp.kill)
|
||||
return log
|
||||
|
||||
|
||||
CUSTOM_CONFIG = ('[options]\nautomatic-rename = false\nstatus-right = "R"\ndefault-command = "exec bash --norc --noprofile"\n'
|
||||
'prefix2 = "F12"\nescape-time = 50\nrepeat-time = 2500\n[keys.root]\n"M-x" = "display-message root-hit"\n"F7" = "split-window -h"\n'
|
||||
'[keys.prefix]\nr = { command = "select-pane -R", repeat = true }\nl = { command = "select-pane -L", repeat = true }\n"C-b" = false\n'
|
||||
'q = "command-prompt -p x { rename-window \\"%%\\" }"\n[keys.mine]\na = "rename-window mine-a"\n')
|
||||
|
||||
|
||||
def run_custom_task(task):
|
||||
py, cpp = make_impls("P2")
|
||||
for impl in (py, cpp):
|
||||
with open(os.path.join(impl.home, "mplex.toml"), "w") as handle:
|
||||
handle.write(CUSTOM_CONFIG)
|
||||
log = []
|
||||
clients = []
|
||||
try:
|
||||
fails = both(lambda: py.build("P2"), lambda: cpp.build("P2"))
|
||||
if fails[0] != fails[1] or fails[0]:
|
||||
return [{"step": "build", "diff": delta(fails[0], fails[1])}]
|
||||
for impl in (py, cpp):
|
||||
clients.append(Client(impl, ["attach-session", "-t", "main"]))
|
||||
script = [b"\x1bx", b"\x1b[18~", b"\x1b[18~", b"\x02r", b"r", b"r", b"l", b"\x02", b"\x1b", b"\x1b[18~l", b"\x02q", b"hello\r", b"\x02" + b"b",
|
||||
b"\x1b[18~", b"\x1b[18~" + b"x", b"\x1b[18~" + b"c", b"\x02l", b"x", b"\x1b[18~[", b"\x02[", b"q", b"\x1bOP", b"\x1b[A"]
|
||||
for number, data in enumerate(script):
|
||||
for client in clients:
|
||||
client.send(data)
|
||||
if number in (4, 9, 16):
|
||||
time.sleep(3.0)
|
||||
for client in clients:
|
||||
client.settle(0.2)
|
||||
screens = [c.text() for c in clients]
|
||||
a, b = both(py.dump, cpp.dump)
|
||||
if screens[0] != screens[1]:
|
||||
log.append({"step": "script %d %r" % (number, data), "kind": "screen", "diff": delta(screens[0], screens[1])})
|
||||
if a != b:
|
||||
log.append({"step": "script %d %r" % (number, data), "kind": "state", "diff": delta(a, b)})
|
||||
# custom table through switch-client -T
|
||||
for impl in (py, cpp):
|
||||
impl.cli(["switch-client", "-T", "mine"])
|
||||
for client in clients:
|
||||
client.send(b"a")
|
||||
a, b = both(py.dump, cpp.dump)
|
||||
if a != b:
|
||||
log.append({"step": "table mine", "kind": "state", "diff": delta(a, b)})
|
||||
finally:
|
||||
for client in clients:
|
||||
client.close()
|
||||
both(py.kill, cpp.kill)
|
||||
return log
|
||||
|
||||
|
||||
def run_task(task):
|
||||
try:
|
||||
if task["kind"] == "cmd":
|
||||
return run_cmd_task(task)
|
||||
if task["kind"] == "key":
|
||||
return run_key_task(task)
|
||||
return run_custom_task(task)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return [{"step": "harness", "error": repr(exc)}]
|
||||
|
||||
|
||||
def main():
|
||||
tasks = json.load(open(TASKS))
|
||||
if FILTER:
|
||||
tasks = [t for t in tasks if any(f in t["id"] for f in FILTER.split(","))]
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
started = time.time()
|
||||
done = mismatched = 0
|
||||
steps = sum(len(t.get("steps", [])) or 1 for t in tasks)
|
||||
report = open(os.path.join(OUT, "mismatches.jsonl"), "w")
|
||||
with concurrent.futures.ThreadPoolExecutor(JOBS) as pool:
|
||||
futures = {pool.submit(run_task, t): t for t in tasks}
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
task = futures[future]
|
||||
log = future.result()
|
||||
done += 1
|
||||
if log:
|
||||
mismatched += 1
|
||||
report.write(json.dumps({"task": task["id"], "log": log}) + "\n")
|
||||
report.flush()
|
||||
if done % 25 == 0 or log:
|
||||
print("[%d/%d] %s %s (%.0fs)" % (done, len(tasks), task["id"], "MISMATCH" if log else "ok", time.time() - started), flush=True)
|
||||
print("tasks %d, steps %d, mismatching tasks %d, %.0fs" % (len(tasks), steps, mismatched, time.time() - started))
|
||||
return 1 if mismatched else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Scenario generator for the mplex differential matrix (taicpp/tests/mplex_matrix_exec.py).
|
||||
|
||||
usage: python3 -I mplex_matrix_gen.py <repo root> <out.json>
|
||||
|
||||
Command tasks: for every command in MPLEX_COMMAND_SPECS and every flag letter of its getopt spec,
|
||||
steps are generated from value pools, grouped in chunks of CHUNK steps; each chunk runs from a
|
||||
freshly built preset on both implementations. Key tasks: every default binding as real key bytes.
|
||||
Placeholders {W} (work dir) are substituted by the executor.
|
||||
"""
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
|
||||
root, out = sys.argv[1], sys.argv[2]
|
||||
spec = importlib.util.spec_from_file_location("tai_mod", root + "/tai.py")
|
||||
tai = importlib.util.module_from_spec(spec)
|
||||
sys.modules["tai_mod"] = tai
|
||||
spec.loader.exec_module(tai)
|
||||
|
||||
CHUNK = 8
|
||||
TARGETS = ["main", "main:1", "main:1.1", "%1", "@1", "$0", "+", "-", "!", "{last}", "{next}", ":.+", ":=0", "{top-left}",
|
||||
"other", "bogus", "main:2", "main:2.{bottom-right}", ":", ":.", "=main", "main:", ":^", ":$", "{end}"]
|
||||
POOLS = {
|
||||
"t": TARGETS, "s": ["main", "other", "new1", "%1", "1", "main:1", "x"],
|
||||
"b": ["b1", "buf", "nosuch"], "n": ["nm", "1 2"], "F": ["#{pane_id}", "#{session_name}:#{window_index}", "x#{bogus}y",
|
||||
"#{?pane_active,A,B}", "#{window_flags}#{pane_index}"],
|
||||
"f": ["1", "0", "#{pane_active}", "#{==:#{pane_index},1}"], "c": ["/tmp", "/nonexistent"],
|
||||
"x": ["40", "10", "0", "abc", "200"], "y": ["10", "5", "0", "abc", "100"], "l": ["5", "50%", "1", "abc", "100%"],
|
||||
"e": ["A=1", "bad"], "E": ["echo"], "N": ["2", "x"], "P": ["p"], "p": ["Q"], "I": ["init"], "T": ["prefix", "root", "copy-mode", "copy-mode-vi", "custom", "ttl"],
|
||||
"d": ["1", "x"], "A": ["%1:on", "x"], "B": ["n:w:#{x}"], "C": ["80x24", "x"], "r": ["x"], "S": ["1"], "K": ["Up"], "O": ["name"], "k": ["x"],
|
||||
}
|
||||
POSITIONAL = {
|
||||
"bind-key": [["F5", "display-message hi"], ["a", "bogus-cmd"], ["C-x", "split-window ; select-pane -L"], ["Zzz", "x"]],
|
||||
"unbind-key": [[], ["F5"], ["C-b"], ["zz"]],
|
||||
"set-option": [["status-position", "top"], ["mouse", "on"], ["base-index", "1"], ["history-limit", "5"], ["status", "off"],
|
||||
["nonexistent", "1"], ["status-left", "[x]"], ["mode-keys", "vi"], ["status-style", "bg=red"], ["status", ""],
|
||||
["prefix", "C-a"], ["synchronize-panes", "on"], ["remain-on-exit", "on"]],
|
||||
"set-window-option": [["synchronize-panes", "on"], ["mode-keys", "vi"], ["x", "1"]],
|
||||
"show-options": [[], ["status"], ["bogus"], ["prefix"]],
|
||||
"show-window-options": [[], ["mode-keys"], ["bogus"]],
|
||||
"send-keys": [["abc"], ["Enter"], ["C-c", "Up"], ["Space", "a"], ["bogusKey"], ["cancel"], ["cursor-up"], ["search-forward-text", "1"]],
|
||||
"source-file": [["/nonexistent"], ["{W}/src.conf"]],
|
||||
"new-window": [[], ["cat"], ["echo", "hi"]], "split-window": [[], ["cat"]], "new-session": [[], ["cat"]],
|
||||
"run-shell": [["echo hi"], ["false"], ["echo #{pane_id}"], []], "if-shell": [["true", "display-message yes", "display-message no"], ["false", "display-message yes"],
|
||||
["false", "display-message yes", "display-message no"]],
|
||||
"command-prompt": [[], ["rename-window %1"]], "confirm-before": [["kill-pane"], ["bogus"]],
|
||||
"find-window": [["bash"], ["zzz"], ["."]], "rename-session": [["newname"], ["a b"]], "rename-window": [["w new"], ["x"]],
|
||||
"load-buffer": [["{W}/buf.txt"], ["/nonexistent"]], "save-buffer": [["{W}/out.txt"]],
|
||||
"set-buffer": [["data"], [], ["a b c"]], "display-message": [[], ["hi #{pane_id}"], ["#{bogus}"]],
|
||||
"display-panes": [[], ["display-message x"]], "resize-pane": [[], ["3"], ["x"]], "select-layout": [[], ["tiled"], ["even-vertical"], ["main-horizontal"], ["bogus"],
|
||||
["main-vertical-mirrored"]],
|
||||
"choose-tree": [[], ["x"]], "choose-buffer": [[], ["x"]], "choose-client": [[], ["x"]],
|
||||
"list-keys": [[], ["F5"], ["C-b"]], "refresh-client": [[], ["x"]], "copy-mode": [[]], "attach-session": [[]],
|
||||
"kill-session": [[]], "capture-pane": [[]],
|
||||
}
|
||||
SKIP_FLAG_VALUES_FOR = {"kill-server"}
|
||||
LAST = {"kill-server"}
|
||||
|
||||
|
||||
def cstr(name):
|
||||
return name
|
||||
|
||||
|
||||
def positional_sets(name, low, high):
|
||||
if name in POSITIONAL:
|
||||
return POSITIONAL[name]
|
||||
if low == 0:
|
||||
return [[]]
|
||||
return [["x"] * low, ["1"] * low]
|
||||
|
||||
|
||||
def flag_letters(spec_text):
|
||||
letters, i = [], 0
|
||||
while i < len(spec_text):
|
||||
takes = i + 1 < len(spec_text) and spec_text[i + 1] == ":"
|
||||
letters.append((spec_text[i], takes))
|
||||
i += 2 if takes else 1
|
||||
return letters
|
||||
|
||||
|
||||
def steps_for(name, aliases, spec_text, low, high, reduced):
|
||||
steps = []
|
||||
pos = positional_sets(name, low, high)
|
||||
if reduced:
|
||||
pos = pos[:2]
|
||||
for p in pos:
|
||||
steps.append([name] + p)
|
||||
for letter, takes in flag_letters(spec_text):
|
||||
values = POOLS.get(letter, ["1", "x"]) if takes else [None]
|
||||
if name in ("command-prompt",) and letter == "T":
|
||||
values = ["command", "search", "target"]
|
||||
if reduced:
|
||||
values = values[:2]
|
||||
for value in values:
|
||||
flag = ["-" + letter] + ([value] if value is not None else [])
|
||||
for p in pos[:3 if not reduced else 1]:
|
||||
steps.append([name] + flag + p)
|
||||
if not reduced:
|
||||
# a few flag pairs
|
||||
letters = [l for l, t in flag_letters(spec_text) if not t][:4]
|
||||
for i in range(len(letters)):
|
||||
for j in range(i + 1, len(letters)):
|
||||
steps.append([name, "-" + letters[i], "-" + letters[j]] + pos[0])
|
||||
if aliases:
|
||||
steps.append([aliases[0]] + pos[0])
|
||||
return steps
|
||||
|
||||
|
||||
COPY_CMDS = ["cancel", "cursor-up", "cursor-down", "cursor-down-and-cancel", "cursor-left", "cursor-right", "start-of-line", "end-of-line",
|
||||
"back-to-indentation", "top-line", "middle-line", "bottom-line", "history-top", "history-bottom", "page-up", "page-down",
|
||||
"page-down-and-cancel", "halfpage-up", "halfpage-down", "halfpage-down-and-cancel", "scroll-up", "scroll-down", "scroll-down-and-cancel",
|
||||
"scroll-middle", "scroll-top", "scroll-bottom", "cursor-centre-vertical", "cursor-centre-horizontal", "goto-line", "refresh-from-pane",
|
||||
"toggle-position", "next-word", "next-word-end", "next-space", "next-space-end", "previous-word", "previous-space", "next-paragraph",
|
||||
"previous-paragraph", "next-matching-bracket", "previous-matching-bracket", "jump-forward", "jump-backward", "jump-to-forward",
|
||||
"jump-to-backward", "jump-again", "jump-reverse", "begin-selection", "stop-selection", "clear-selection", "select-line", "select-word",
|
||||
"rectangle-toggle", "rectangle-on", "rectangle-off", "other-end", "set-mark", "jump-to-mark", "copy-selection", "copy-selection-and-cancel",
|
||||
"copy-pipe", "copy-pipe-and-cancel", "append-selection", "append-selection-and-cancel", "copy-end-of-line", "copy-end-of-line-and-cancel",
|
||||
"copy-pipe-end-of-line", "copy-pipe-end-of-line-and-cancel", "copy-line", "copy-line-and-cancel", "copy-pipe-line", "copy-pipe-line-and-cancel",
|
||||
"search-forward", "search-backward", "search-forward-text", "search-backward-text", "search-forward-incremental",
|
||||
"search-backward-incremental", "search-again", "search-reverse"]
|
||||
ARGS = {"goto-line": ["5", "x"], "jump-forward": ["3"], "jump-backward": ["1"], "jump-to-forward": ["9"], "jump-to-backward": ["2"],
|
||||
"search-forward": ["1"], "search-backward": ["9"], "search-forward-text": ["1"], "search-backward-text": ["5"],
|
||||
"search-forward-incremental": ["=7", "+8"], "search-backward-incremental": ["=3"], "copy-pipe": ["cat >/dev/null"]}
|
||||
|
||||
|
||||
def main():
|
||||
tasks = []
|
||||
presets = ["P0", "P1", "P2", "P3", "P4", "P5"]
|
||||
for name, (aliases, spec_text, low, high) in tai.MPLEX_COMMAND_SPECS.items():
|
||||
for preset in presets:
|
||||
reduced = preset != "P3"
|
||||
steps = steps_for(name, list(aliases), spec_text, low, high, reduced)
|
||||
# drop steps that would hang a CLI or are pure noise
|
||||
if name == "kill-server":
|
||||
steps = [[name]]
|
||||
chunks = [steps[i:i + CHUNK] for i in range(0, len(steps), CHUNK)] or [[]]
|
||||
if reduced:
|
||||
chunks = chunks[:1]
|
||||
for number, chunk in enumerate(chunks):
|
||||
if name == "kill-server" and preset != "P2":
|
||||
continue
|
||||
tasks.append({"id": "cmd:%s:%s:%d" % (name, preset, number), "kind": "cmd", "preset": preset, "steps": chunk})
|
||||
# copy mode commands from the copy-mode presets (emacs P4, vi P5), eight per fresh chunk
|
||||
for preset in ("P4", "P5"):
|
||||
steps = []
|
||||
for cmd in COPY_CMDS:
|
||||
steps.append(["send-keys", "-X", cmd] + ARGS.get(cmd, []))
|
||||
for number in range(0, len(steps), CHUNK):
|
||||
tasks.append({"id": "copy:%s:%d" % (preset, number // CHUNK), "kind": "cmd", "preset": preset + "c", "steps": steps[number:number + CHUNK]})
|
||||
# key bindings
|
||||
for table, preset in (("prefix", "P2"), ("prefix", "P3"), ("copy-mode", "P4c"), ("copy-mode-vi", "P5c")):
|
||||
for key_table, key, command, repeat, note in tai.MPLEX_DEFAULT_KEYS:
|
||||
if key_table != table:
|
||||
continue
|
||||
tasks.append({"id": "key:%s:%s:%s" % (table, preset, key), "kind": "key", "preset": preset, "table": table, "key": key})
|
||||
# extra key scenarios: root table rebinds, repeat, prefix2, escape-time
|
||||
tasks.append({"id": "key:custom", "kind": "custom"})
|
||||
json.dump(tasks, open(out, "w"))
|
||||
print(len(tasks), "tasks", sum(len(t.get("steps", [])) for t in tasks), "steps")
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
# Differential matrix: every mplex command/flag/target and every default key binding on tai.py and on
|
||||
# the C++ port, from several preset states. Writes only under taicpp/build/.
|
||||
# usage (from taicpp/): sh tests/mplex_matrix_run.sh [filter] [jobs]
|
||||
set -e
|
||||
cd "$(dirname "$0")/.."
|
||||
make -f build/int/Makefile -j8 build/int/mplex_cli
|
||||
python3 -I tests/mplex_matrix_gen.py .. build/matrix/tasks.json
|
||||
python3 -I tests/mplex_matrix_exec.py .. build/int/mplex_cli build/matrix/tasks.json build/matrix/run --jobs "${2:-12}" ${1:+--filter "$1"}
|
||||
Reference in New Issue
Block a user