1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
//! OSC 8 hyperlink tests (#26): cells printed under an open link carry a link
//! index that resolves to the URI, survives scroll, and stops at close.
//!
//! Driven through the public API — feed OSC 8 + text, read the link index via
//! `Engine::link_at` / `viewport_link_at` (the link rides a per-row map now, not
//! the cell, #46) and resolve via `Engine::hyperlink`.
use justerm_core::{Engine, decode, encode};
const OPEN: &[u8] = b"\x1b]8;;https://example.com\x07";
const CLOSE: &[u8] = b"\x1b]8;;\x07";
#[test]
fn cells_under_open_link_carry_the_uri() {
let mut t = Engine::new(80, 24);
t.feed(OPEN);
t.feed(b"ab");
t.feed(CLOSE);
assert_eq!(t.grid().cell(0, 0).c(), 'a');
assert_eq!(
t.link_at(0, 0).as_ref().map(|h| h.uri()),
Some("https://example.com"),
"'a' should carry the link",
);
// Both cells of one link resolve to the same URI — and since #628 they share the
// same allocation rather than an index into a table, which `grid.rs`'s
// `ext_attrs_round_trip_from_one_column_to_another` pins by pointer identity.
assert_eq!(
t.link_at(0, 1).as_ref().map(|h| h.uri()),
Some("https://example.com")
);
}
#[test]
fn text_before_open_and_after_close_has_no_link() {
let mut t = Engine::new(80, 24);
t.feed(b"x");
t.feed(OPEN);
t.feed(b"y");
t.feed(CLOSE);
t.feed(b"z");
assert_eq!(t.grid().cell(0, 0).c(), 'x');
assert_eq!(t.link_at(0, 0), None);
assert!(t.link_at(0, 1).is_some()); // 'y'
assert_eq!(t.grid().cell(0, 2).c(), 'z');
assert_eq!(t.link_at(0, 2), None);
}
#[test]
fn sgr_reset_does_not_close_a_hyperlink() {
// OSC 8 close is the only closer — an SGR reset must not drop the link.
let mut t = Engine::new(80, 24);
t.feed(OPEN);
t.feed(b"a\x1b[0mb"); // SGR reset between the two linked glyphs
assert!(t.link_at(0, 0).is_some());
assert_eq!(t.link_at(0, 1), t.link_at(0, 0));
}
#[test]
fn wide_glyph_lead_and_spacer_share_the_link() {
let mut t = Engine::new(80, 24);
t.feed(OPEN);
t.feed("世".as_bytes());
t.feed(CLOSE);
assert_eq!(t.grid().cell(0, 0).c(), '世');
assert!(t.link_at(0, 0).is_some());
assert_eq!(t.link_at(0, 1), t.link_at(0, 0)); // spacer shares the lead's link
}
#[test]
fn link_survives_scroll_into_scrollback() {
// The link rides the row's map into scrollback — the renderer can still
// resolve a link in history.
let mut t = Engine::new(80, 2);
t.feed(OPEN);
t.feed(b"L");
t.feed(CLOSE);
t.feed(b"\r\nsecond\r\n"); // line-feed at the bottom evicts row 0 ('L') to scrollback
assert!(t.scrollback_len() >= 1);
t.scroll_up(1);
assert_eq!(t.viewport_line(0)[0].c(), 'L');
assert_eq!(
t.viewport_link_at(0, 0).as_ref().map(|h| h.uri()),
Some("https://example.com"),
"link survives scroll into scrollback",
);
}
#[test]
fn frame_carries_a_scrolled_back_links_uri() {
// #48: `frame()` sources cells *and* the per-row link map from the viewport
// at `display_offset`. With the link scrolled into history, the frame's
// link_table must still carry the URI and the span must reference it —
// otherwise a wire consumer (which only sees `frame()`) loses the link.
let mut t = Engine::new(80, 2);
t.feed(OPEN);
t.feed(b"L");
t.feed(CLOSE);
t.feed(b"\r\nsecond\r\n"); // evicts the linked row 0 into scrollback
t.scroll_up(1); // viewport row 0 is the linked 'L' again
let frame = t.frame();
assert_eq!(
frame.link_table,
vec!["https://example.com".to_string()],
"the scrolled-back link did not reach the frame's link_table",
);
let span = frame
.spans
.iter()
.find(|s| s.line == 0)
.expect("full frame covers row 0");
let idx = span.links.get(&0).expect("col 0 references the link");
assert_eq!(
frame.link_table[idx.get() as usize - 1],
"https://example.com"
);
}
#[test]
fn plain_output_carries_no_link() {
let mut t = Engine::new(80, 24);
t.feed(b"plain");
assert_eq!(t.link_at(0, 0), None);
}
#[test]
fn link_follows_an_insert_shift() {
// ICH shifts cells right — the link map must follow, like combining (#46).
let mut t = Engine::new(6, 1);
t.feed(OPEN);
t.feed(b"L");
t.feed(CLOSE); // col0 'L' linked
t.feed(b"\x1b[1;1H"); // cursor home
t.feed(b"\x1b[2@"); // ICH 2 -> 'L' shifts to col2
assert_eq!(t.grid().cell(0, 2).c(), 'L');
assert_eq!(
t.link_at(0, 2).as_ref().map(|h| h.uri()),
Some("https://example.com"),
"link followed the insert shift"
);
assert_eq!(t.link_at(0, 0), None, "the opened gap carries no link");
}
#[test]
fn link_follows_a_delete_shift() {
// DCH shifts the tail left — the link map must follow.
let mut t = Engine::new(6, 1);
t.feed(b"xy");
t.feed(OPEN);
t.feed(b"L");
t.feed(CLOSE); // col0 'x', col1 'y', col2 'L' linked
t.feed(b"\x1b[1;1H"); // cursor home
t.feed(b"\x1b[2P"); // DCH 2 -> 'L' shifts to col0
assert_eq!(t.grid().cell(0, 0).c(), 'L');
assert_eq!(
t.link_at(0, 0).as_ref().map(|h| h.uri()),
Some("https://example.com"),
"link followed the delete shift"
);
}
#[test]
fn link_survives_resize_reflow() {
// A column resize reflows rows; the link map is re-keyed per column (#46).
let mut t = Engine::new(5, 2);
t.feed(OPEN);
t.feed(b"L");
t.feed(CLOSE);
t.resize(3, 2); // column change -> reflow
assert_eq!(
t.link_at(0, 0).as_ref().map(|h| h.uri()),
Some("https://example.com"),
"link survives reflow"
);
}
#[test]
fn hyperlink_round_trips_through_serialization() {
let mut t = Engine::new(80, 24);
t.feed(OPEN);
t.feed(b"hi");
t.feed(CLOSE);
let frame = t.frame();
// The frame carries the URI in its own (frame-local) side-table.
assert_eq!(frame.link_table, vec!["https://example.com".to_string()]);
// Full round-trip: the span's per-column link indices + the link_table survive.
let decoded = decode(&encode(&frame)).expect("decode");
assert_eq!(decoded, frame);
let span = &decoded.spans[0];
let hcol = span
.cells
.iter()
.position(|c| c.c() == 'h')
.expect("'h' present");
let idx = span
.links
.get(&hcol)
.copied()
.expect("decoded span keeps the link");
assert_eq!(
decoded.link_table[idx.get() as usize - 1],
"https://example.com"
);
}
/// A URI carrying an unencoded `;` survives whole (#650).
///
/// `OSC 8 ; params ; URI` is `;`-separated, so vte hands the handler the URI in pieces; reading
/// `params[2]` alone kept the first piece and dropped the rest, silently. xterm.js special-cases
/// exactly this — it splits on the **first** `;` only, *"to support unencoded semi-colons in the
/// URIs"* (`InputHandler.ts:3106`) — and nothing is lost at the parser, so this is a rejoin.
///
/// Reachable without anything exotic: `?a=1;b=2` is a legal query string, and `;` is a legal
/// filename byte, so `ls --hyperlink=auto` can emit one inside a `file://` URI.
#[test]
fn a_uri_keeps_its_unencoded_semicolons() {
let mut t = Engine::new(60, 2);
t.feed(b"\x1b]8;;https://example.com/p?a=1;b=2\x07X\x1b]8;;\x07");
assert_eq!(
t.link_at(0, 0).map(|h| h.uri().to_owned()).as_deref(),
Some("https://example.com/p?a=1;b=2"),
"the tail after the first ';' belongs to the URI, not to another parameter",
);
}
/// The URI field and the `id=` field are independent: a semicolon-carrying URI still groups, and
/// grouping still keys on the *whole* URI (#635 + #650 together).
///
/// Worth its own test because the two fixes touch the same handler: rejoining the tail changes what
/// the group key is built from, so a rejoin that dropped the tail *after* keying would group two
/// different URIs that happen to share a prefix.
#[test]
fn a_semicolon_uri_still_groups_by_id_on_its_whole_value() {
let mut t = Engine::new(60, 4);
t.feed(b"\x1b]8;id=q;https://x/p?a=1;b=2\x07A\x1b]8;;\x07\r\n");
t.feed(b"\x1b]8;id=q;https://x/p?a=1;b=2\x07B\x1b]8;;\x07");
assert_eq!(
t.link_at(0, 0).map(|h| h.uri().to_owned()).as_deref(),
Some("https://x/p?a=1;b=2"),
"the id= parse must not have eaten part of the URI",
);
assert_eq!(
t.frame().link_table.len(),
1,
"same id, same whole URI — one link",
);
// …and a URI differing only *after* the first ';' is a different link. This is the assertion a
// key built from the truncated value would fail.
let mut u = Engine::new(60, 4);
u.feed(b"\x1b]8;id=q;https://x/p?a=1;b=2\x07A\x1b]8;;\x07\r\n");
u.feed(b"\x1b]8;id=q;https://x/p?a=1;b=9\x07B\x1b]8;;\x07");
assert_eq!(
u.frame().link_table.len(),
2,
"the group key sees the whole URI, so these are two links",
);
}
/// The rejoin must not swallow the close. `OSC 8 ; ;` arrives as `["8", "", ""]`, whose rejoined
/// tail is the empty string — still a close (#650).
#[test]
fn an_empty_uri_still_closes_after_the_rejoin() {
let mut t = Engine::new(60, 2);
t.feed(b"\x1b]8;;https://example.com/a;b\x07X\x1b]8;;\x07Y");
assert!(t.link_at(0, 0).is_some(), "X is inside the link");
assert!(
t.link_at(0, 1).is_none(),
"Y is after the close — a rejoin that produced a non-empty URI would leave it open",
);
}
/// A percent-encoded `%3B` is passed through untouched — the engine never decodes a URI.
///
/// Kept as a test rather than a comment because it is the control that pinned #650's cause to the
/// *raw* `;`, and because it forecloses a future "fix" that starts decoding: `Hyperlink::uri` hands
/// the target over exactly as declared, and whether it is openable is consumer policy (ADR-0017).
#[test]
fn a_percent_encoded_semicolon_is_not_decoded() {
let mut t = Engine::new(60, 2);
t.feed(b"\x1b]8;;https://example.com/a%3Bb=c\x07X\x1b]8;;\x07");
assert_eq!(
t.link_at(0, 0).map(|h| h.uri().to_owned()).as_deref(),
Some("https://example.com/a%3Bb=c"),
);
}
/// An `id=`-grouped link reaches the consumer as **one** link across two lines (#635).
///
/// This is the assertion that matches the user-visible symptom, and it is deliberately
/// made after `encode`/`decode` rather than on the `Frame`: a consumer never sees an
/// engine-side allocation, it sees a frame-local index into `link_table`, and grouping
/// cells by that index is literally what `justerm-web/src/links.ts` does. So "hovering
/// one half of a wrapped link highlights the other half" is true exactly when the two
/// rows' indices are equal here — an in-crate `Arc` identity check cannot say that.
#[test]
fn an_id_grouped_link_round_trips_as_one_link_across_two_lines() {
let mut t = Engine::new(20, 4);
// The case `id=` exists for: one logical link the application had to emit as two runs.
t.feed(b"\x1b]8;id=grp;https://example.com/split\x07first\x1b]8;;\x07\r\n");
t.feed(b"\x1b]8;id=grp;https://example.com/split\x07second\x1b]8;;\x07");
let frame = t.frame();
let decoded = decode(&encode(&frame)).expect("decode");
assert_eq!(decoded, frame, "round-trip is lossless");
// One entry, because one link — the whole point. Two entries is the pre-#635 defect,
// and it is what the consumer would group by.
assert_eq!(
decoded.link_table,
vec!["https://example.com/split".to_string()],
"the grouped link ships once, not once per run",
);
// Both runs point at it. Collected across every span so this does not depend on how
// the damage happened to be split into spans.
let mut indices: Vec<u32> = decoded
.spans
.iter()
.flat_map(|s| s.links.values().map(|i| i.get()))
.collect();
indices.dedup();
indices.sort_unstable();
indices.dedup();
assert_eq!(
indices,
vec![1],
"every linked cell on both lines carries the same link index",
);
// And the halves really are on different lines — otherwise the assertion above would
// be satisfied by one run and prove nothing about grouping.
let lines: std::collections::BTreeSet<u16> = decoded
.spans
.iter()
.filter(|s| !s.links.is_empty())
.map(|s| s.line)
.collect();
assert_eq!(lines.len(), 2, "the two runs are on two separate lines");
}