strop-terminal 0.44.0

Owned terminal sessions (PTY on the namespace's worker) and bounded VT snapshots for strop
use super::*;
use strop_core::frontend_input::Input;

fn terminal(columns: u16, rows: u16) -> Vt {
    themed_terminal(columns, rows, None)
}

fn themed_terminal(columns: u16, rows: u16, palette: Option<&Palette>) -> Vt {
    Vt::new(
        SessionId::from_request(strop_core::worker::WorkerId::new(1)),
        Geometry {
            columns,
            rows,
            revision: 1,
        },
        palette,
    )
    .unwrap()
}

#[test]
fn soft_wrap_and_grapheme_cells_share_the_readonly_text_projection() {
    let mut vt = terminal(5, 3);
    vt.feed("\x1b[?2027hA👩\u{200d}💻BCx".as_bytes()).unwrap();
    let (frame, _) = vt.snapshot().unwrap();
    let first = &frame.rows[0].row;
    assert!(first.wrapped);
    assert_eq!(first.symbol(1), Some("👩\u{200d}💻"));
    assert_eq!(first.cells[1].width, 2);
    assert_eq!(first.cells[2].width, 0);
    assert_eq!(first.byte_at(1), first.byte_at(2));
    assert!(frame.projection.to_string().starts_with("A👩\u{200d}💻BCx"));
    assert_eq!(frame.cursor.row, 1);
    assert_eq!(frame.cursor.column, 1);
    assert_eq!(frame.cursor_byte(), "A👩\u{200d}💻BCx".len());
}

#[test]
fn continuing_output_cannot_mutate_an_inspection_snapshot() {
    let mut vt = terminal(12, 3);
    vt.feed(b"one\r\ntwo\r\nthree\r\n").unwrap();
    let (before, _) = vt.snapshot().unwrap();
    let before_text = before.projection.to_string();
    vt.feed(b"four\r\nfive\r\nlatest\r\n").unwrap();
    let (after, _) = vt.snapshot().unwrap();
    let after_text = after.projection.to_string();
    assert_eq!(before.projection.to_string(), before_text);
    assert!(!before_text.contains("latest"));
    assert!(after_text.contains("one"));
    assert!(after_text.contains("latest"));
    assert!(after.history_rows > before.history_rows);
}

#[test]
fn erased_cells_retain_their_terminal_background_color() {
    let mut vt = terminal(10, 2);
    vt.feed(b"\x1b[41m\x1b[2J").unwrap();
    let (frame, _) = vt.snapshot().unwrap();
    assert_eq!(frame.rows[0].row.symbol(0), Some(" "));
    assert_eq!(
        frame.rows[0].row.cells[0].style.background,
        Color::Indexed(1)
    );
}

#[test]
fn clipboard_reads_are_denied_and_multiline_paste_requires_explicit_consent() {
    let mut vt = terminal(20, 3);
    let query = vt.feed(b"\x1b]52;c;?\x07").unwrap();
    assert!(query.reply.is_empty());
    let paste = Input::Paste("first\nsecond".into());
    assert!(matches!(
        vt.input(&paste, false),
        Err(Error::PasteNeedsConfirmation)
    ));
    let accepted = vt.input(&paste, true).unwrap();
    assert!(String::from_utf8(accepted.reply)
        .unwrap()
        .contains("second"));
    vt.feed(b"\x1b[?2004h").unwrap();
    let bracketed = vt.input(&paste, false).unwrap().reply;
    assert!(bracketed.starts_with(b"\x1b[200~"));
    assert!(bracketed.ends_with(b"\x1b[201~"));
}

#[test]
fn nul_retains_modifiers_and_negotiated_repeat_release_semantics() {
    use strop_core::frontend_input::{KeyCode, KeyEvent, KeyKind};
    let mut vt = terminal(20, 3);
    let mut key = KeyEvent::press(KeyCode::Null);
    key.modifiers.alt = true;
    assert_eq!(vt.input(&Input::Key(key), false).unwrap().reply, b"\x1b\0");
    vt.feed(b"\x1b[>3u").unwrap();
    assert_eq!(
        vt.input(&Input::Key(key), false).unwrap().reply,
        b"\x1b[32;7u"
    );
    key.kind = KeyKind::Repeat;
    assert_eq!(
        vt.input(&Input::Key(key), false).unwrap().reply,
        b"\x1b[32;7:2u"
    );
    key.kind = KeyKind::Release;
    assert_eq!(
        vt.input(&Input::Key(key), false).unwrap().reply,
        b"\x1b[32;7:3u"
    );
}

#[test]
fn terminal_escape_precedes_the_next_literal_key() {
    use strop_core::frontend_input::{KeyCode, KeyEvent};
    let mut vt = terminal(20, 3);
    let escape = vt
        .input(&Input::Key(KeyEvent::press(KeyCode::Escape)), false)
        .unwrap();
    assert_eq!(escape.reply, b"\x1b");
    let letter = vt
        .input(&Input::Key(KeyEvent::press(KeyCode::Char('x'))), false)
        .unwrap();
    assert_eq!(letter.reply, b"x");
}

#[test]
fn oversized_paste_refusal_leaves_the_terminal_encoder_usable() {
    let mut vt = terminal(20, 3);
    vt.feed(b"\x1b[?2004h").unwrap();
    let oversized = Input::Paste("x".repeat(MAX_INPUT_BYTES));
    assert!(matches!(
        vt.input(&oversized, false),
        Err(Error::Capacity(_))
    ));
    assert_eq!(
        vt.input(&Input::Paste("safe".into()), false).unwrap().reply,
        b"\x1b[200~safe\x1b[201~"
    );
}

#[test]
fn replay_admission_checks_real_grapheme_and_projection_correspondence() {
    let mut vt = terminal(5, 3);
    vt.feed("\x1b[?2027hA👩\u{200d}💻BCx".as_bytes()).unwrap();
    let (frame, _) = vt.snapshot().unwrap();
    frame.validate().unwrap();
    let update = Update {
        session: frame.session,
        phase: Phase::Running,
        frame: Some(frame.clone()),
        effects: Vec::new(),
        acknowledged_input: 0,
        warning: None,
    };
    let wire = serde_json::to_value(&update).unwrap();
    let decoded: Update = serde_json::from_value(wire.clone()).unwrap();
    let decoded = decoded.frame.unwrap();
    assert_eq!(decoded.projection, frame.projection);
    assert_eq!(decoded.cell_at_byte(1).unwrap().width, 2);
    let mut corrupt = wire;
    corrupt["frame"]["geometry"]["columns"] = serde_json::json!(4);
    assert!(serde_json::from_value::<Update>(corrupt).is_err());
}

#[test]
fn focus_reports_follow_the_child_negotiated_mode() {
    let mut vt = terminal(20, 3);
    assert!(vt.focus(false).unwrap().reply.is_empty());
    vt.feed(b"\x1b[?1004h").unwrap();
    assert_eq!(vt.focus(false).unwrap().reply, b"\x1b[O");
    assert_eq!(vt.focus(true).unwrap().reply, b"\x1b[I");
    vt.feed(b"\x1b[?1004l").unwrap();
    assert!(vt.focus(true).unwrap().reply.is_empty());
}

#[test]
fn keyboard_advertisement_matches_the_captured_frontend_profile() {
    let mut vt = terminal(20, 3);
    vt.keyboard_capabilities(0).unwrap();
    assert_eq!(vt.feed(b"\x1b[?u\x1b[c").unwrap().reply, b"\x1b[?62;22c");
    vt.feed(b"\x1b[>31u").unwrap();
    assert!(vt.feed(b"\x1b[?u").unwrap().reply.is_empty());
    let mut alt =
        strop_core::frontend_input::KeyEvent::press(strop_core::frontend_input::KeyCode::Char('x'));
    alt.modifiers.alt = true;
    assert_eq!(vt.input(&Input::Key(alt), false).unwrap().reply, b"\x1bx");
    vt.keyboard_capabilities(SUPPORTED_KEYBOARD_FLAGS).unwrap();
    assert_eq!(vt.feed(b"\x1b[?u").unwrap().reply, b"\x1b[?27u");
}

#[test]
fn osc_and_window_attempts_stay_in_their_namespace() {
    // 0055 §12: title, cwd, clipboard, window and hyperlink attempts from
    // the child become scoped editor metadata or an explicit denial —
    // never host window control, filesystem navigation or a reply that
    // probes the outer terminal.
    let mut vt = terminal(40, 3);
    let title = vt.feed(b"\x1b]0;title-attempt\x07").unwrap();
    assert_eq!(title.reply, b"");
    assert!(matches!(
        title.effects.as_slice(),
        [Effect::Title(text)] if text == "title-attempt"
    ));
    let title_two = vt.feed(b"\x1b]2;title-2\x07").unwrap();
    assert!(matches!(
        title_two.effects.as_slice(),
        [Effect::Title(text)] if text == "title-2"
    ));
    let directory = vt.feed(b"\x1b]7;file://elsewhere/home\x07").unwrap();
    assert_eq!(directory.reply, b"");
    assert!(matches!(
        directory.effects.as_slice(),
        [Effect::ReportedDirectory(bytes)] if bytes == b"file://elsewhere/home"
    ));
    let clipboard = vt.feed(b"\x1b]52;c;aGVsbG8=\x07").unwrap();
    assert_eq!(clipboard.reply, b"");
    assert_eq!(clipboard.effects.as_slice(), [Effect::ClipboardWriteDenied]);
    // Window size queries and title-stack pushes answer nothing: the child
    // reads only the admitted geometry through normal mode reports.
    for probe in [
        b"\x1b[18t".as_slice(),
        b"\x1b[19t",
        b"\x1b[22t",
        b"\x1b[23t",
    ] {
        let window = vt.feed(probe).unwrap();
        assert_eq!(window.reply, b"", "no host probing via {probe:?}");
        assert!(window.effects.is_empty());
    }
    // Hyperlink targets do not reach the host; the link text stays text.
    let link = vt
        .feed(b"\x1b]8;;scheme://example\x07link\x1b]8;;\x07")
        .unwrap();
    assert_eq!(link.reply, b"");
    assert!(link.effects.is_empty());
    assert!(vt
        .snapshot()
        .unwrap()
        .0
        .projection
        .to_string()
        .starts_with("link"));
}

#[test]
fn the_embedder_palette_configures_defaults_and_survives_indexed_output() {
    // 0065 D3: the embedder-owned palette lands at creation; indexed and
    // default colors resolve through it, and one Arc serves every frame
    // until the child overrides a color itself.
    let palette = Palette::for_theme(strop_core::theme::ThemeId::Dark);
    let mut vt = themed_terminal(20, 3, Some(&palette));
    vt.feed(b"\x1b[31mred \x1b[38;2;1;2;3mtrue \x1b[mplain")
        .unwrap();
    let (frame, _) = vt.snapshot().unwrap();
    assert_eq!(frame.palette.foreground, palette.foreground);
    assert_eq!(frame.palette.background, palette.background);
    assert_eq!(frame.palette.colors, palette.colors);
    let row = &frame.rows[0].row;
    assert_eq!(row.symbol(0), Some("r"));
    assert_eq!(row.cells[0].style.foreground, Color::Indexed(1));
    assert_eq!(
        row.cells[4].style.foreground,
        Color::Rgb(Rgb {
            red: 1,
            green: 2,
            blue: 3,
        })
    );
    assert_eq!(row.cells[9].style.foreground, Color::Default);
    let (again, _) = vt.snapshot().unwrap();
    assert!(
        Arc::ptr_eq(&frame.palette, &again.palette),
        "an unchanged palette is one Arc, cloned per frame never per cell"
    );
    // A child's OSC 4 override of an index still wins over the embedder
    // default for that index (native palette-set semantics).
    vt.feed(b"\x1b]4;1;rgb:12/34/56\x07\x1b[31mx").unwrap();
    let (overridden, _) = vt.snapshot().unwrap();
    assert_eq!(
        overridden.palette.colors[1],
        Rgb {
            red: 0x12,
            green: 0x34,
            blue: 0x56,
        }
    );
}

#[test]
fn live_defaults_preserve_osc_overrides_and_reset_to_the_selected_theme() {
    use strop_core::theme::ThemeId;

    let original = Palette::for_theme(ThemeId::Dark);
    let selected = Palette::for_theme(ThemeId::CatppuccinMocha);
    let mut vt = themed_terminal(20, 3, Some(&original));
    vt.feed(b"\x1b[31mI\x1b[38;2;1;2;3;48;2;4;5;6mT\x1b[mD")
        .unwrap();
    // An override equal to the OLD default must still count as child-owned.
    // Value comparison cannot replace Ghostty's per-index override mask.
    let same = original.colors[2];
    vt.feed(
        format!(
            "\x1b]4;1;rgb:12/34/56;2;rgb:{:02x}/{:02x}/{:02x}\x07\
             \x1b]10;rgb:23/45/67\x07\x1b]11;rgb:34/56/78\x07",
            same.red, same.green, same.blue
        )
        .as_bytes(),
    )
    .unwrap();
    let (before, _) = vt.snapshot().unwrap();
    assert_eq!(
        before.palette.foreground,
        Rgb {
            red: 0x23,
            green: 0x45,
            blue: 0x67
        }
    );
    assert_eq!(
        before.palette.background,
        Rgb {
            red: 0x34,
            green: 0x56,
            blue: 0x78
        }
    );
    assert_eq!(
        before.palette.colors[1],
        Rgb {
            red: 0x12,
            green: 0x34,
            blue: 0x56
        }
    );
    vt.configure_palette(&selected).unwrap();
    let (after, emission) = vt.snapshot().unwrap();
    assert!(emission.reply.is_empty());
    assert!(emission.effects.is_empty());
    assert!(after.revision > before.revision);
    assert_eq!(after.projection, before.projection);
    assert_eq!(after.cursor, before.cursor);
    assert_eq!(after.geometry, before.geometry);
    assert_eq!(after.origin, before.origin);
    assert_eq!(after.palette.foreground, before.palette.foreground);
    assert_eq!(after.palette.background, before.palette.background);
    assert_eq!(after.palette.colors[1], before.palette.colors[1]);
    assert_eq!(after.palette.colors[2], same);
    assert_eq!(after.palette.colors[3], selected.colors[3]);
    let style = after.rows[0].row.cells[1].style;
    assert_eq!(
        style.foreground,
        Color::Rgb(Rgb {
            red: 1,
            green: 2,
            blue: 3
        })
    );
    assert_eq!(
        style.background,
        Color::Rgb(Rgb {
            red: 4,
            green: 5,
            blue: 6
        })
    );
    assert_eq!(
        after.rows[0].row.cells[0].style.foreground,
        Color::Indexed(1)
    );
    assert_eq!(after.rows[0].row.cells[2].style.foreground, Color::Default);

    // Selective OSC 104 reset leaves other child-owned entries alone.
    vt.feed(b"\x1b]104;1\x07\x1b]110\x07\x1b]111\x07").unwrap();
    let (reset, _) = vt.snapshot().unwrap();
    assert_eq!(reset.palette.foreground, selected.foreground);
    assert_eq!(reset.palette.background, selected.background);
    assert_eq!(reset.palette.colors[1], selected.colors[1]);
    assert_eq!(reset.palette.colors[2], same);
    // Reset-all also uses the new default, never the launch-time palette.
    vt.feed(b"\x1b]104\x07").unwrap();
    let (reset_all, _) = vt.snapshot().unwrap();
    assert_eq!(*reset_all.palette, selected);
    assert_eq!(reset_all.rows[0].row.cells[1].style, style);
    let (unchanged, _) = vt.snapshot().unwrap();
    assert!(Arc::ptr_eq(&reset_all.palette, &unchanged.palette));
}

#[test]
fn every_registered_theme_initializes_and_rethemes_an_existing_terminal() {
    use strop_core::theme::ThemeId;

    let mut existing = terminal(10, 2);
    existing.feed(b"unchanged").unwrap();
    for theme in ThemeId::ALL {
        let palette = Palette::for_theme(theme);
        let mut fresh = themed_terminal(10, 2, Some(&palette));
        assert_eq!(*fresh.snapshot().unwrap().0.palette, palette);
        existing.configure_palette(&palette).unwrap();
        let (frame, _) = existing.snapshot().unwrap();
        assert_eq!(*frame.palette, palette);
        assert!(frame.projection.to_string().starts_with("unchanged"));
    }
}