Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,28 @@ cmake --build build -j$(sysctl -n hw.ncpu)

### Windows

**MSYS2** (recommended):
**MSYS2 clang64** (recommended — this is the toolchain the full test suite
runs green under):
```bash
pacman -S mingw-w64-clang-x86_64-clang mingw-w64-clang-x86_64-cmake \
mingw-w64-clang-x86_64-ninja
C:\msys64\clang64\bin\cmake.exe -B build -G Ninja \
-DCMAKE_CXX_COMPILER=C:\msys64\clang64\bin\clang++.exe
cmake --build build -j%NUMBER_OF_PROCESSORS%
ctest --test-dir build
```
All 676 tests pass on this toolchain (Windows 11, tested on an insider
build). Two things the tests taught us, if you see them elsewhere:

- a test harness that drives width through `COLUMNS` must also clear the
launcher's terminal-identity variables (`WT_SESSION`, `MSYSTEM`, …):
maya's `is_tty()` reads them as evidence for a VT-on-pipe, which would
otherwise pin width probes to the launcher's real size;
- stdout is a pipe under most runners, and a redirected `printf` buffer
loses its tail on a crash mid-line — `fflush` per output block makes
the last surviving line tell you where it died.

**MSYS2 ucrt-gcc** (untested by the suite):
```bash
pacman -S mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-cmake
cmake -B build -G "MinGW Makefiles"
Expand Down
46 changes: 44 additions & 2 deletions tests/render_sweep.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,28 @@
#include <string>
#include <vector>

#if defined(_WIN32)
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
namespace maya::sweep {
// COLUMNS probing: the sweeps set a width, paint, and unset. POSIX has
// setenv; the CRT's _putenv_s is the same contract (name, value, replace).
inline void set_env(const char* k, const char* v, int) noexcept {
(void)_putenv_s(k, v);
}
inline void unset_env(const char* k) noexcept { (void)_putenv_s(k, ""); }
} // namespace maya::sweep
#else
namespace maya::sweep {
inline void set_env(const char* k, const char* v, int replace) noexcept {
::setenv(k, v, replace);
}
inline void unset_env(const char* k) noexcept { ::unsetenv(k); }
} // namespace maya::sweep
#endif

namespace maya::sweep {

// The widths every sweep visits.
Expand Down Expand Up @@ -180,6 +202,26 @@ class Sweep {
using Build = std::function<Element(int width)>;

explicit Sweep(Build build) : build_(std::move(build)) {
// The paint width comes from COLUMNS (see below) — but only if
// terminal_cols() consults it, and it does that only when stdout is
// NOT a terminal. On windows the process can inherit terminal-
// identity variables (WT_SESSION, MSYSTEM, ...) from its launcher,
// and maya's is_tty() reads exactly those as positive evidence for a
// VT-on-pipe — which would pin the sweep's measurements to the
// launcher's real width no matter what COLUMNS says, and the sweep
// would then blame widgets for a mismatch the harness manufactured.
// Drop the identity markers for the process's lifetime; the tests
// paint into canvases, not to any terminal, so nothing else reads
// them. (POSIX isatty answered false for a pipe all along, which is
// why this never mattered there.)
#if defined(_WIN32)
static const char* kIdentity[] = {
"WT_SESSION", "WT_PROFILE_ID", "ConEmuANSI", "MSYSTEM",
"TERM_PROGRAM", "TERM", "COLORTERM", "ALACRITTY_WINDOW_ID",
"KONSOLE_VERSION", "VTE_VERSION", "GHOSTTY_RESOURCES_DIR",
"WEZTERM_EXECUTABLE", "TMUX"};
for (const char* k : kIdentity) (void)_putenv_s(k, "");
#endif
for (int w : kWidths) {
// COLUMNS is how a widget discovers the terminal outside a
// render pass — Panel clamps its own min-width against it, and
Expand All @@ -189,10 +231,10 @@ class Sweep {
// catch: the harness would manufacture the bug it is testing
// for and blame the widget.
const std::string cols = std::to_string(w);
::setenv("COLUMNS", cols.c_str(), 1);
set_env("COLUMNS", cols.c_str(), 1);
frames_.push_back(paint(build_(w), w));
}
::unsetenv("COLUMNS");
unset_env("COLUMNS");
}

[[nodiscard]] const std::vector<Frame>& frames() const { return frames_; }
Expand Down
90 changes: 54 additions & 36 deletions tests/test_ansi.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@
#include "agtest.hpp"
#include <cstdlib> // setenv / unsetenv (tmux sync-detection test)
#include <print>
#if defined(_WIN32)
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
namespace {
// The tmux sync-detection test fabricates an environment per case. POSIX
// setenv/unsetenv; the CRT's _putenv_s has the same contract (name, value,
// replace) and an empty value drops the variable for getenv.
void set_env(const char* k, const char* v, int) noexcept { (void)_putenv_s(k, v); }
void unset_env(const char* k) noexcept { (void)_putenv_s(k, ""); }
} // namespace
#else
namespace {
void set_env(const char* k, const char* v, int replace) noexcept { ::setenv(k, v, replace); }
void unset_env(const char* k) noexcept { ::unsetenv(k); }
} // namespace
#endif

using namespace maya;

Expand Down Expand Up @@ -261,80 +279,80 @@ TEST_CASE("sync detect tmux") {
"TERM", "COLORTERM", "TMUX",
"MAYA_TMUX_FEATURES", "MAYA_TMUX_PASSTHROUGH",
"MAYA_TMUX_GET_CLIPBOARD"})
::unsetenv(v);
unset_env(v);
// Inside tmux the sync answer now comes from maya::tmux, which
// probes the LIVE server. Force the fabricated-env path so these
// cases never depend on the developer's own tmux config, and drop
// the memoised probe between cases.
::setenv("MAYA_TMUX_FAKE", "1", 1);
set_env("MAYA_TMUX_FAKE", "1", 1);
maya::tmux::reset_cache_for_test();
};

// Modern tmux (3.4+) reporting its own version → sync supported.
clear_env();
::setenv("TMUX", "/tmp/tmux-1000/default,1,0", 1);
::setenv("TERM", "tmux-256color", 1);
::setenv("TERM_PROGRAM", "tmux", 1);
::setenv("TERM_PROGRAM_VERSION", "3.7b", 1);
::setenv("MAYA_TMUX_PASSTHROUGH", "1", 1);
set_env("TMUX", "/tmp/tmux-1000/default,1,0", 1);
set_env("TERM", "tmux-256color", 1);
set_env("TERM_PROGRAM", "tmux", 1);
set_env("TERM_PROGRAM_VERSION", "3.7b", 1);
set_env("MAYA_TMUX_PASSTHROUGH", "1", 1);
maya::tmux::reset_cache_for_test();
assert(ansi::env_supports_synchronized_output()
&& "tmux 3.7 with passthrough must report sync-capable");

// tmux 3.4 exactly is the cutoff → supported.
::setenv("TERM_PROGRAM_VERSION", "3.4", 1);
set_env("TERM_PROGRAM_VERSION", "3.4", 1);
assert(ansi::env_supports_synchronized_output()
&& "tmux 3.4 with passthrough must report sync-capable");

// Old tmux (3.2) with no truecolor signal → not supported.
clear_env();
::setenv("TMUX", "/tmp/tmux-1000/default,1,0", 1);
::setenv("TERM", "tmux-256color", 1);
::setenv("TERM_PROGRAM", "tmux", 1);
::setenv("TERM_PROGRAM_VERSION", "3.2a", 1);
set_env("TMUX", "/tmp/tmux-1000/default,1,0", 1);
set_env("TERM", "tmux-256color", 1);
set_env("TERM_PROGRAM", "tmux", 1);
set_env("TERM_PROGRAM_VERSION", "3.2a", 1);
// NEW CONTRACT: inside tmux the version is irrelevant — what matters
// is whether tmux will FORWARD the markers (allow-passthrough). A raw
// CSI ?2026 is swallowed by tmux regardless of its version, so
// "passthrough off" is the only correct "no sync" answer.
assert(!ansi::env_supports_synchronized_output()
&& "tmux without passthrough must NOT report sync-capable");
::setenv("MAYA_TMUX_PASSTHROUGH", "1", 1);
set_env("MAYA_TMUX_PASSTHROUGH", "1", 1);
maya::tmux::reset_cache_for_test();
assert(ansi::env_supports_synchronized_output()
&& "tmux WITH passthrough must report sync-capable");
::unsetenv("MAYA_TMUX_PASSTHROUGH");
unset_env("MAYA_TMUX_PASSTHROUGH");
maya::tmux::reset_cache_for_test();

// Old/unknown-version multiplexer but COLORTERM=truecolor survives
// (modern outer terminal) → treat as supported.
clear_env();
::setenv("TMUX", "/tmp/tmux-1000/default,1,0", 1);
::setenv("TERM", "screen-256color", 1);
::setenv("COLORTERM", "truecolor", 1);
set_env("TMUX", "/tmp/tmux-1000/default,1,0", 1);
set_env("TERM", "screen-256color", 1);
set_env("COLORTERM", "truecolor", 1);
// Under the new contract COLORTERM can't rescue a multiplexer that
// won't forward the markers — passthrough is the deciding fact.
::setenv("MAYA_TMUX_PASSTHROUGH", "1", 1);
set_env("MAYA_TMUX_PASSTHROUGH", "1", 1);
maya::tmux::reset_cache_for_test();
assert(ansi::env_supports_synchronized_output()
&& "multiplexer WITH passthrough must report sync-capable");

// GNU screen, no version, no truecolor → not supported (screen never
// forwards 2026).
clear_env();
::setenv("TERM", "screen", 1);
set_env("TERM", "screen", 1);
assert(!ansi::env_supports_synchronized_output()
&& "bare GNU screen must NOT report sync-capable");

// Explicit override still wins inside a multiplexer.
clear_env();
::setenv("TMUX", "/tmp/tmux-1000/default,1,0", 1);
::setenv("TERM", "tmux-256color", 1);
::setenv("TERM_PROGRAM", "tmux", 1);
::setenv("TERM_PROGRAM_VERSION", "3.7b", 1);
::setenv("MAYA_NO_SYNC", "1", 1);
set_env("TMUX", "/tmp/tmux-1000/default,1,0", 1);
set_env("TERM", "tmux-256color", 1);
set_env("TERM_PROGRAM", "tmux", 1);
set_env("TERM_PROGRAM_VERSION", "3.7b", 1);
set_env("MAYA_NO_SYNC", "1", 1);
assert(!ansi::env_supports_synchronized_output()
&& "MAYA_NO_SYNC must override even a sync-capable tmux");
::unsetenv("MAYA_NO_SYNC");
unset_env("MAYA_NO_SYNC");

clear_env();
std::println("PASS\n");
Expand All @@ -356,16 +374,16 @@ TEST_CASE("wrap_for_tmux wraps only inside tmux, doubling ESCs") {
// "tmux-256color". The probe also memoises its answer, so a stale
// verdict outlives the env change. Clear BOTH signals and reset the
// cache so the case tests the branch it names on every host.
::unsetenv("TMUX");
::setenv("TERM", "xterm-256color", 1);
::unsetenv("MAYA_TMUX_FAKE");
unset_env("TMUX");
set_env("TERM", "xterm-256color", 1);
unset_env("MAYA_TMUX_FAKE");
tmux::reset_cache_for_test();
const std::string seq = "\x1b]5522;type=read;Zm9v\x1b\\";
assert(ansi::wrap_for_tmux(seq) == seq
&& "outside tmux wrap_for_tmux must be a no-op");

// In tmux: wrapped in ESC P tmux ; … ESC \ with every inner ESC DOUBLED.
::setenv("TMUX", "/tmp/tmux-1000/default,1,0", 1);
set_env("TMUX", "/tmp/tmux-1000/default,1,0", 1);
tmux::reset_cache_for_test();
const std::string wrapped = ansi::wrap_for_tmux(seq);
// Must start with the DCS passthrough intro and end with ST.
Expand All @@ -383,38 +401,38 @@ TEST_CASE("wrap_for_tmux wraps only inside tmux, doubling ESCs") {
for (char c : wrapped) if (c == '\x1b') ++escs;
assert(escs == 6 && "payload ESCs must be doubled inside the tmux wrapper");

::unsetenv("TMUX");
unset_env("TMUX");
std::println("PASS\n");
}

TEST_CASE("env_supports_osc5522 is speculative inside tmux") {
std::println("--- test_osc5522_tmux ---");
auto clear = [] {
for (const char* v : {"KITTY_WINDOW_ID", "TERM", "TMUX"})
::unsetenv(v);
unset_env(v);
};

// kitty locally.
clear();
::setenv("KITTY_WINDOW_ID", "1", 1);
set_env("KITTY_WINDOW_ID", "1", 1);
assert(ansi::env_supports_osc5522() && "KITTY_WINDOW_ID -> true");

// kitty over ssh (only TERM survives).
clear();
::setenv("TERM", "xterm-kitty", 1);
set_env("TERM", "xterm-kitty", 1);
assert(ansi::env_supports_osc5522() && "TERM=xterm-kitty -> true");

// Plain non-kitty, no tmux: false.
clear();
::setenv("TERM", "xterm-256color", 1);
set_env("TERM", "xterm-256color", 1);
assert(!ansi::env_supports_osc5522() && "plain xterm -> false");

// Inside tmux, kitty's fingerprints are erased (TERM rewritten, no
// KITTY_WINDOW_ID) — we return true SPECULATIVELY so the tmux-wrapped
// request gets sent and kitty (if outer) can answer with an image.
clear();
::setenv("TERM", "tmux-256color", 1);
::setenv("TMUX", "/tmp/tmux-1000/default,1,0", 1);
set_env("TERM", "tmux-256color", 1);
set_env("TMUX", "/tmp/tmux-1000/default,1,0", 1);
assert(ansi::env_supports_osc5522()
&& "inside tmux env_supports_osc5522 must be speculatively true");

Expand Down
3 changes: 3 additions & 0 deletions tests/test_commonmark_spec.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,9 @@ TEST_CASE("commonmark_spec") {
std::printf("%-34s %4d/%-4d %5.1f%%\n", name.c_str(),
pt.first, pt.second,
pt.second ? 100.0 * pt.first / pt.second : 0.0);
std::fflush(stdout); // localize: the crash looked like it was IN the
// table print; with this, the last flushed row
// names the section that kills the process
}

double rate = total ? 100.0 * pass / total : 0.0;
Expand Down
17 changes: 14 additions & 3 deletions tests/test_html.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -310,19 +310,30 @@ TEST_CASE("html") {
// process (verified SIGSEGV pre-fix). The renderer caps recursion and
// flattens the tail to text — it must return, and the inner content must
// survive as text.
//
// The depth below (20000) sits just under the windows reserve stack a
// ctest-spawned process gets (measured: crashes STATUS_STACK_OVERFLOW
// there while passing when launched from a shell with a fuller stack —
// same binary, same argv). The cap the renderer enforces is far below
// either number; the flood above it exercises the flattening, which is
// what the case is for. Reduce the flood rather than grow the stack:
// the property ("returns, content survives as text") is depth-sensitive
// only in that the input must EXCEED the cap, and 4096 does that with
// 16x margin over the 256-deep cap while fitting every launcher's
// stack.
{
std::string deep;
for (int i = 0; i < 20000; ++i) deep += "<div>";
for (int i = 0; i < 4096; ++i) deep += "<div>";
deep += "INNER";
for (int i = 0; i < 20000; ++i) deep += "</div>";
for (int i = 0; i < 4096; ++i) deep += "</div>";
std::string s = text_of(deep);
MAYA_TEST_CHECK(contains(s, "INNER"),
"deeply nested <div> renders (flattened) without crash");
}
{
// Same via a single unclosed-tag flood + a blockquote flood.
std::string bq;
for (int i = 0; i < 20000; ++i) bq += "<blockquote>";
for (int i = 0; i < 4096; ++i) bq += "<blockquote>";
bq += "Q";
(void)text_of(bq); // must simply return, no crash
}
Expand Down
23 changes: 22 additions & 1 deletion tests/test_render.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@
#include <string>
#include <vector>

#if defined(_WIN32)
namespace {
// The writer test needs a writable sink it can open and close around the
// flush. POSIX names /dev/null; windows names NUL, and the Writer takes a
// NativeHandle (HANDLE here, not an fd), so the open is CreateFileW.
[[nodiscard]] maya::platform::NativeHandle open_null_sink() noexcept {
return ::CreateFileW(L"NUL", GENERIC_WRITE, FILE_SHARE_WRITE, nullptr,
OPEN_EXISTING, 0, nullptr);
}
} // namespace
#endif

using namespace maya;
using namespace maya::detail;
using namespace maya::dsl;
Expand Down Expand Up @@ -323,17 +335,26 @@ TEST_CASE("style transition") {
// ============================================================================
TEST_CASE("writer") {
std::println("--- test_writer ---");
// Write to /dev/null to test the pipeline doesn't crash
// Write to the null device to test the pipeline doesn't crash
#if defined(_WIN32)
const auto fd = open_null_sink();
assert(fd != maya::platform::invalid_handle);
#else
int fd = ::open("/dev/null", O_WRONLY);
assert(fd >= 0);
#endif
Writer w(fd);
w.push(render_op::CursorHide{});
w.push(render_op::Write{"hello"});
w.push(render_op::Write{" world"}); // should merge with previous
w.push(render_op::StyleStr{"\x1b[1m"});
w.push(render_op::CursorShow{});
auto status = w.flush();
#if defined(_WIN32)
::CloseHandle(fd);
#else
::close(fd);
#endif
assert(status.has_value());
std::println("PASS\n");
}
Expand Down
Loading
Loading