test(conformance): retain geometry evidence for native resize failures

Keep resize assertions strict while preserving before/after observations, bounded prepaint traces, and Linux kernel PTY geometry after a failure. Cover diagnostic failures and cleanup; retain dependency caches when later conformance checks fail.
This commit is contained in:
kuddev committed 2026-09-06 20:38:20 +08:00
1 parent af7db8400d
commit 270d23c106
4 files changed
+233

No files matched your search

+17
View File
@@ -104,6 +104,7 @@ jobs:
scripts.tests.test_app_icons
scripts.tests.test_git_attributes
scripts.conformance.tests.test_harness
scripts.conformance.tests.test_resize
- name: Compile Python helpers
run: python3 -m compileall -q scripts/preview_release.py scripts/conformance
@@ -143,6 +144,8 @@ jobs:
toolchain: ${{ env.RUST_TOOLCHAIN }}
- uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
- name: Test workspace natively
run: cargo test --locked --workspace
@@ -187,6 +190,8 @@ jobs:
tar -tzf "$archive" "$root/usr/share/applications/io.github.kuddev.nebula.preview.desktop"
- name: Run AppImage conformance under X11
env:
NEBULA_RESIZE_TRACE: "1"
run: |
xvfb-run -a -s "-screen 0 1600x1000x24" \
python3 scripts/conformance/run.py \
@@ -195,6 +200,8 @@ jobs:
--output dist/conformance/linux-appimage-report.json
- name: Run AppImage conformance under Wayland
env:
NEBULA_RESIZE_TRACE: "1"
run: |
runtime_directory="$RUNNER_TEMP/nebula-wayland-runtime"
mkdir -p "$runtime_directory" dist/conformance
@@ -235,6 +242,8 @@ jobs:
--output dist/conformance/linux-appimage-wayland-report.json
- name: Install and run Debian package conformance under X11
env:
NEBULA_RESIZE_TRACE: "1"
run: |
sudo apt-get install -y \
"./dist/NebulaTerminal-v$ASSET_VERSION-linux-x86_64.deb"
@@ -298,6 +307,8 @@ jobs:
toolchain: ${{ env.RUST_TOOLCHAIN }}
- uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
- name: Test workspace natively
run: cargo test --locked --workspace
@@ -371,6 +382,8 @@ jobs:
--force "${signing_args[@]}"
- name: Mount final DMG and run conformance
env:
NEBULA_RESIZE_TRACE: "1"
run: |
dmg="dist/NebulaTerminal-v$ASSET_VERSION-macos-${{ matrix.architecture }}.dmg"
mount_point="$RUNNER_TEMP/nebula-preview-${{ matrix.architecture }}"
@@ -436,6 +449,8 @@ jobs:
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
- uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
- name: Test workspace natively
run: cargo test --locked --workspace
- name: Test SSH confirmation and password dialogs
@@ -443,6 +458,8 @@ jobs:
- name: Build GPUI comparison binary
run: cargo build --locked --release -p nebula --bin nebula --features gpui-shell
- name: Run Windows conformance
env:
NEBULA_RESIZE_TRACE: "1"
run: >-
python scripts/conformance/run.py --app target/release/nebula.exe
--platform windows-x86_64 --output dist/conformance/windows-report.json
@@ -226,6 +226,29 @@ impl Element for TerminalElement {
let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
let view = self.view.read(cx);
// Bounded, opt-in evidence that a real layout reached this pane. PTY
// resize traces alone cannot distinguish missing prepaint from a
// viewport which still matches the spawn grid.
static TRACE_ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
static TRACE_SAMPLES: std::sync::atomic::AtomicUsize =
std::sync::atomic::AtomicUsize::new(0);
if *TRACE_ENABLED.get_or_init(|| std::env::var_os("NEBULA_RESIZE_TRACE").is_some())
&& TRACE_SAMPLES.fetch_add(1, std::sync::atomic::Ordering::Relaxed) < 256
{
crate::gpui_shell::try_write_stderr(format_args!(
"[nebula:resize-trace] prepaint pane={} origin=({:.2},{:.2}) content={:.2}x{:.2} cell={:.2}x{:.2} scale={:.2} viewport={}x{}",
view.pane_id,
bounds.origin.x.as_f32(),
bounds.origin.y.as_f32(),
bounds.size.width.as_f32(),
bounds.size.height.as_f32(),
cell_width.as_f32(),
line_height.as_f32(),
scale,
view.grid_cols(),
view.grid_rows(),
));
}
TermLayout {
cell_width,
line_height,
+70
View File
@@ -1,8 +1,65 @@
from __future__ import annotations
import json
import os
from conformance.harness import ConformanceContext, require
def _linux_terminal_size(pid: int) -> dict[str, int]:
import fcntl
import struct
import termios
# Read the shell's actual PTY geometry without relying on COLUMNS or the
# command-substitution stdout used by tput. O_NOCTTY preserves ownership.
fd = os.open(f"/proc/{pid}/fd/0", os.O_RDONLY | os.O_NOCTTY | os.O_CLOEXEC)
try:
packed = fcntl.ioctl(fd, termios.TIOCGWINSZ, bytes(8))
rows, columns, _, _ = struct.unpack("HHHH", packed)
return {"rows": rows, "columns": columns}
finally:
os.close(fd)
def _write_failure_diagnostics(
ctx: ConformanceContext,
source: int,
sibling: int,
measurements: dict[str, int],
error: Exception,
) -> None:
details: dict[str, object] = {
"error": str(error),
"shell": ctx.shell,
"measurements": measurements,
"panes": {},
}
try:
details["snapshot"] = ctx.api("runtime.snapshot", timeout=2.0)
except Exception as diagnostic_error:
details["snapshot_error"] = str(diagnostic_error)
panes: dict[str, object] = {}
for pane_id in (source, sibling):
params = {"window_id": ctx.window_id, "pane_id": pane_id}
pane: dict[str, object] = {}
try:
pane["read"] = ctx.api("pane.read", {**params, "lines": 40}, timeout=2.0)
except Exception as diagnostic_error:
pane["read_error"] = str(diagnostic_error)
if ctx.platform_family == "linux":
try:
processes = ctx.api("pane.procs", params, timeout=2.0)
pane["kernel_terminal_size"] = _linux_terminal_size(int(processes["root_pid"]))
except Exception as diagnostic_error:
pane["kernel_terminal_size_error"] = str(diagnostic_error)
panes[str(pane_id)] = pane
details["panes"] = panes
(ctx.artifact_dir / "resize-failure.json").write_text(
json.dumps(details, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
def run(ctx: ConformanceContext) -> dict[str, object]:
source = ctx.pane_id
split = ctx.api(
@@ -14,16 +71,21 @@ def run(ctx: ConformanceContext) -> dict[str, object]:
},
)
sibling = int(split["action"]["pane_id"])
measurements: dict[str, int] = {}
try:
source_before = ctx.measure_columns(source)
measurements["source_columns_before"] = source_before
sibling_before = ctx.measure_columns(sibling)
measurements["sibling_columns_before"] = sibling_before
resized = ctx.api(
"pane.resize",
{"window_id": ctx.window_id, "pane_id": source, "ratio": 0.70},
)
ratio = float(resized["action"]["ratio"])
source_after = ctx.measure_columns(source)
measurements["source_columns_after"] = source_after
sibling_after = ctx.measure_columns(sibling)
measurements["sibling_columns_after"] = sibling_after
require(abs(ratio - 0.70) < 0.001, f"runtime reported wrong split ratio: {ratio}")
require(
source_after > source_before,
@@ -45,6 +107,14 @@ def run(ctx: ConformanceContext) -> dict[str, object]:
"sibling_columns_before": sibling_before,
"sibling_columns_after": sibling_after,
}
except Exception as error:
# Capture only after a strict assertion fails. Extra observations must
# not turn into an implicit wait that makes a resize race disappear.
try:
_write_failure_diagnostics(ctx, source, sibling, measurements, error)
except Exception as diagnostic_error:
print(f"[resize] could not retain diagnostics: {diagnostic_error}", flush=True)
raise
finally:
ctx.best_effort_api("pane.close", {"window_id": ctx.window_id, "pane_id": sibling})
ctx.best_effort_api("window.focus", {"window_id": ctx.window_id, "pane_id": source})
+123
View File
@@ -0,0 +1,123 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
SCRIPTS_DIR = Path(__file__).resolve().parents[2]
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
from conformance.cases import resize
from conformance.harness import ConformanceError
class ResizeContext:
window_id = 1
pane_id = 10
shell = "bash"
platform_family = "macos"
def __init__(self, artifact_dir: Path, columns: list[int]) -> None:
self.artifact_dir = artifact_dir
self.columns = iter(columns)
self.cleanup: list[str] = []
def api(self, method: str, params=None, **kwargs):
if method == "pane.split":
return {"action": {"pane_id": 11}}
if method == "pane.resize":
return {"action": {"ratio": params["ratio"]}}
if method == "runtime.snapshot":
return {"windows": [{"id": self.window_id}]}
if method == "pane.read":
return {"text": "NEBULA_COLS_3_116"}
raise AssertionError(method)
def measure_columns(self, pane_id: int) -> int:
return next(self.columns)
def best_effort_api(self, method: str, params) -> None:
self.cleanup.append(method)
class ResizeDiagnosticsTests(unittest.TestCase):
def test_growing_and_shrinking_panes_pass_without_diagnostic_sampling(self) -> None:
with tempfile.TemporaryDirectory() as directory:
ctx = ResizeContext(Path(directory), [57, 56, 80, 33])
with patch.object(resize, "_write_failure_diagnostics") as diagnose:
result = resize.run(ctx)
self.assertTrue(result["pty_columns_changed"])
diagnose.assert_not_called()
self.assertEqual(ctx.cleanup, ["pane.close", "window.focus"])
def test_unchanged_spawn_width_still_fails_and_preserves_all_measurements(self) -> None:
with tempfile.TemporaryDirectory() as directory:
ctx = ResizeContext(Path(directory), [116, 116, 116, 116])
with self.assertRaisesRegex(ConformanceError, "70% pane did not grow: 116 -> 116"):
resize.run(ctx)
details = json.loads((Path(directory) / "resize-failure.json").read_text())
self.assertEqual(details["measurements"], {
"source_columns_before": 116,
"sibling_columns_before": 116,
"source_columns_after": 116,
"sibling_columns_after": 116,
})
self.assertEqual(set(details["panes"]), {"10", "11"})
self.assertEqual(ctx.cleanup, ["pane.close", "window.focus"])
def test_sibling_and_total_assertions_remain_strict(self) -> None:
for columns, message in [
([57, 56, 80, 56], "30% pane did not shrink"),
([57, 56, 85, 15], "PTY column total changed"),
]:
with self.subTest(columns=columns), tempfile.TemporaryDirectory() as directory:
ctx = ResizeContext(Path(directory), columns)
with self.assertRaisesRegex(ConformanceError, message):
resize.run(ctx)
self.assertEqual(ctx.cleanup, ["pane.close", "window.focus"])
def test_diagnostic_failure_cannot_replace_the_resize_failure(self) -> None:
with tempfile.TemporaryDirectory() as directory:
ctx = ResizeContext(Path(directory), [116] * 4)
with patch.object(resize, "_write_failure_diagnostics", side_effect=OSError("read-only")), \
patch("builtins.print"):
with self.assertRaisesRegex(ConformanceError, "70% pane did not grow"):
resize.run(ctx)
self.assertEqual(ctx.cleanup, ["pane.close", "window.focus"])
@unittest.skipUnless(sys.platform == "linux", "reads the Linux process PTY")
def test_kernel_probe_follows_pty_geometry_even_with_stale_columns_environment(self) -> None:
import fcntl
import struct
import termios
master, slave = os.openpty()
child = None
try:
child = subprocess.Popen(
[sys.executable, "-c", "import time; time.sleep(30)"],
stdin=slave, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
env={**os.environ, "COLUMNS": "116"},
)
for columns in (57, 82):
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 30, columns, 0, 0))
self.assertEqual(
resize._linux_terminal_size(child.pid),
{"rows": 30, "columns": columns},
)
finally:
if child is not None:
child.terminate()
child.wait(timeout=5)
os.close(master)
os.close(slave)
if __name__ == "__main__":
unittest.main()