diff --git a/taicpp/docs/wiring/mplex.md b/taicpp/docs/wiring/mplex.md index eb79765..4da1bdb 100644 --- a/taicpp/docs/wiring/mplex.md +++ b/taicpp/docs/wiring/mplex.md @@ -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 `, ...). +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). diff --git a/taicpp/tests/mplex_matrix_exec.py b/taicpp/tests/mplex_matrix_exec.py new file mode 100644 index 0000000..b8e6c9d --- /dev/null +++ b/taicpp/tests/mplex_matrix_exec.py @@ -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 [--jobs N] [--filter TEXT] + +Normalized (only these): the TAI_HOME and work directory paths (replaced by and ), 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, "").replace(self.work, "").replace(self.tmp, "") + for regex in TIME_RES: + text = regex.sub("