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:
1 parent
af7db8400d
commit
270d23c106
4 files changed
+233
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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})
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user