1// the namespace import is a valid Scope
2log satisfies log.Scope;
3
4describe("log scopes", () => {
5 test("scope metadata attaches to messages", () => {
6 const messages: log.Message[] = [];
7 const root = log.headlessScope((m) => messages.push(m));
8
9 const req = root.scoped("http", { method: "GET", path: "/" });
10 req.info("incoming");
11 req.write("chunk");
12 ASSERT(messages[0]?.custom?.method === "GET");
13 ASSERT(messages[1]?.custom?.path === "/");
14
15 // nested scopes merge their fields over inherited ones
16 const child = req.scoped("auth", { method: "POST", user: "clo" });
17 child.warn("denied");
18 ASSERT(messages[2]?.custom?.method === "POST");
19 ASSERT(messages[2]?.custom?.path === "/");
20 ASSERT(messages[2]?.custom?.user === "clo");
21
22 // sibling scopes are unaffected by the child's overrides
23 req.scoped("other").info("hi");
24 ASSERT(messages[3]?.custom?.method === "GET");
25 ASSERT(messages[3]?.custom?.user === undefined);
26
27 // scopes without metadata emit none
28 root.scoped("plain").info("hello");
29 ASSERT(messages[4]?.custom === undefined);
30 });
31});
32
33/** what clears a widget block from its first row, after a resize */
34const erase = "\r" + ansi.clearFullLine + ansi.cursorRight(1) + ansi.clearToEndOfScreen + "\r";
35
36// these tests for `startWidget` are built on a custom widget host
37// implementation that buffers all bytes in memory. it is a great
38// example of how modular the entire system is.
39describe("log widgets", () => {
40 test("write buffering", () => {
41 const host = new testing.MockScreen();
42 host.writeOutput("hello world ");
43 host.writeOutput("and so on\n");
44 host.expectFrame(0, {
45 stdout: "hello world and so on\n",
46 });
47 host.writeOutput("more logs\n");
48 host.expectFrame(0, {
49 stdout: "more logs\n",
50 });
51 host.writeOutput("abc def");
52 {
53 using lock = host.getDrawLock("long");
54 host.expectFrame(null, {
55 stdout: "abc def",
56 });
57 lock.release("cursor-start-of-line");
58 }
59 host.expectFrame(null, { stdout: "" });
60 {
61 const lock = host.getDrawLock("long");
62 host.writeOutput("abc def ");
63 host.writeOutput("hijk");
64 lock.release("cursor-start-of-line");
65 host.expectFrame(0, {
66 stdout: "abc def hijk",
67 });
68 }
69 host.expectFrame(null, { stdout: "" });
70 host.cancel();
71 });
72
73 test("widget partial line", () => {
74 const host = new testing.MockScreen();
75 const w1 = host.startWidget({ format: ({ now }) => `t=${now}` });
76 host.expectFrame(0, {
77 stderr: testing.MockScreen.sync([
78 "t=0\n",
79 ]),
80 });
81 host.expectNone();
82 host.writeOutput("partial line");
83 host.expectFrame(0, {
84 merged: testing.MockScreen.sync([
85 ansi.cursorUp(1),
86 ansi.clearFullLine,
87 "partial line",
88 "\n",
89 "t=0\n",
90 ]),
91 stdout: "partial line",
92 stderr: testing.MockScreen.sync([
93 ansi.cursorUp(1),
94 ansi.clearFullLine,
95 "\n",
96 "t=0\n",
97 ]),
98 });
99 host.writeOutput(" and more\n");
100 // partial line
101 // t=0
102 // [C]
103 host.expectFrame(0, {
104 merged: testing.MockScreen.sync([
105 ansi.cursorUp(2) + ansi.cursorRight("partial line".length),
106 " and more\n",
107 "t=0\n",
108 ]),
109 stdout: " and more\n",
110 stderr: testing.MockScreen.sync([
111 ansi.cursorUp(2) + ansi.cursorRight("partial line".length),
112 "t=0\n",
113 ]),
114 });
115 host.cancel();
116 });
117
118 test("widget partial line 2", () => {
119 const host = new testing.MockScreen();
120 const w1 = host.startWidget({ format: () => `abc\ndefg` });
121 host.expectFrame(0, {
122 stderr: testing.MockScreen.sync([
123 "abc\ndefg\n",
124 ]),
125 });
126 // abc
127 // defg
128 host.expectNone();
129 host.writeOutput("partial line");
130 // abc
131 // defg
132 // [c]
133 host.expectFrame(0, {
134 merged: testing.MockScreen.sync([
135 ansi.cursorUp(2),
136 ansi.clearFullLine,
137 "partial line",
138 "\n",
139 ansi.clearFullLine,
140 "abc\ndefg\n",
141 ]),
142 });
143 host.writeOutput(" and more\na");
144 // partial line[T]
145 // abc
146 // defg
147 // [CURSOR]
148 // move up 3, right 12
149 host.expectFrame(0, {
150 merged: testing.MockScreen.sync([
151 ansi.cursorUp(2),
152 ansi.clearFullLine,
153 ansi.cursorUp(1) + ansi.cursorRight(12),
154 " and more\n",
155 "a",
156 "\n",
157 ansi.clearFullLine,
158 "abc\ndefg\n",
159 ]),
160 });
161 host.cancel();
162 });
163
164 test("widget with interweaving logs", () => {
165 const host = new testing.MockScreen();
166
167 using _ = host.startWidget({
168 format: ({ now }) => `line one ${now}\nline two`,
169 });
170
171 host.expectFrame(0, {
172 stderr: testing.MockScreen.sync([
173 "line one 0\nline two\n",
174 ]),
175 });
176
177 host.writeOutput("info: line of log\n");
178 host.expectFrame(0, {
179 merged: testing.MockScreen.sync([
180 ansi.cursorUp(2),
181 ansi.clearFullLine,
182 "info: line of log\n",
183 "line one 0\nline two\n",
184 ]),
185 });
186
187 host.writeOutput("warn: line of log\n");
188 host.writeOutput("error: line of log\n");
189 host.expectFrame(0, {
190 merged: testing.MockScreen.sync([
191 ansi.cursorUp(1),
192 ansi.clearFullLine,
193 ansi.cursorUp(1),
194 ansi.clearFullLine,
195 "warn: line of log\n",
196 "error: line of log\n",
197 "line one 0\nline two\n",
198 ]),
199 });
200 host.cancel();
201 });
202
203 test("temporary drawing lock", () => {
204 const host = new testing.MockScreen();
205
206 using _ = host.startWidget({
207 format: ({ now }) => `line one ${now}\nline two`,
208 });
209
210 host.expectFrame(0, {
211 stderr: testing.MockScreen.sync([
212 "line one 0\nline two\n",
213 ]),
214 });
215
216 host.writeOutput("info: line of log\n");
217 host.expectFrame(0, {
218 merged: testing.MockScreen.sync([
219 ansi.cursorUp(2),
220 ansi.clearFullLine,
221 "info: line of log\n",
222 "line one 0\nline two\n",
223 ]),
224 });
225
226 {
227 host.writeOutput("warn: line of log\n");
228 host.expectWithoutConsume(0);
229 const lock = host.getDrawLock("short");
230 // info: line of log
231 // line one 0
232 // line two
233 // [C]
234 host.expectFrame(null, {
235 merged: [
236 ansi.syncStart,
237 ansi.cursorUp(1),
238 ansi.clearFullLine,
239 ansi.cursorUp(1),
240 ansi.clearFullLine,
241 "warn: line of log\n",
242 ].join(""),
243 stdout: "warn: line of log\n",
244 });
245 // info: line of log
246 // warn: line of log
247 // [C]
248 const lock2 = host.getDrawLock("long");
249 host.expectNone();
250 host.writeOutput("error: line of log\n");
251 host.expectNone();
252 lock2.release("cursor-start-of-line");
253 host.expectNone();
254 lock.release("cursor-start-of-line");
255 // warn: line of log
256 // [c]
257 host.expectFrame(0, {
258 merged: [
259 "error: line of log\n",
260 "line one 0\nline two\n",
261 ansi.syncEnd,
262 ].join(""),
263 });
264 host.cancel();
265 }
266 {
267 const lock = host.getDrawLock("short");
268 // info: line of log
269 // line one 0
270 // line two
271 // [C]
272 host.expectFrame(null, {
273 merged: [
274 ansi.syncStart,
275 ansi.cursorUp(1),
276 ansi.clearFullLine,
277 ansi.cursorUp(1),
278 ansi.clearFullLine,
279 ].join(""),
280 stdout: "",
281 });
282 const lock2 = host.getDrawLock("long");
283 host.expectNone();
284 lock2.release("cursor-start-of-line");
285 host.expectNone();
286 lock.release("cursor-start-of-line");
287 // warn: line of log
288 // [c]
289 host.expectFrame(0, {
290 merged: [
291 "line one 0\nline two\n",
292 ansi.syncEnd,
293 ].join(""),
294 });
295 host.cancel();
296 }
297 });
298
299 test("draw lock middle of cursor restoration short", () => {
300 const host = new testing.MockScreen();
301
302 using _ = host.startWidget({
303 format: ({ now }) => `line one ${now}\nline two`,
304 });
305
306 host.expectFrame(0, {
307 stderr: testing.MockScreen.sync([
308 "line one 0\nline two\n",
309 ]),
310 });
311
312 host.writeOutput("info: line of log\n");
313 host.expectFrame(0, {
314 merged: testing.MockScreen.sync([
315 ansi.cursorUp(2),
316 ansi.clearFullLine,
317 "info: line of log\n",
318 "line one 0\nline two\n",
319 ]),
320 });
321
322 host.writeOutput("warn: line of log\n");
323 host.expectWithoutConsume(0);
324 const lock = host.getDrawLock("short");
325 // info: line of log
326 // line one 0
327 // line two
328 // [C]
329 host.expectFrame(null, {
330 merged: [
331 ansi.syncStart,
332 ansi.cursorUp(1),
333 ansi.clearFullLine,
334 ansi.cursorUp(1),
335 ansi.clearFullLine,
336 "warn: line of log\n",
337 ].join(""),
338 stdout: "warn: line of log\n",
339 });
340 // info: line of log
341 // warn: line of log
342 // [C]
343 const lock2 = host.getDrawLock("long");
344 host.expectNone();
345 // now we will pretend the environment writes "hello " without
346 // a newline. without extra write calls, it has to save and
347 // restore the cursor perfectly. when multiple locks are held,
348 // it's a bit ambiguous, so the implementation takes the slow path
349 // if any releases are uncertain of the terminal state.
350 lock2.release("cursor-middle-of-line");
351 host.expectNone();
352 lock.release("cursor-start-of-line");
353 // warn: line of log
354 // [c]
355 host.expectFrame(0, {
356 merged: [
357 ansi.cursorSave,
358 "\n",
359 "line one 0\nline two\n",
360 ansi.syncEnd,
361 ].join(""),
362 });
363 const lock3 = host.getDrawLock("short");
364 // hello [S]
365 // line one 0
366 // line two
367 // [C]
368 host.expectFrame(null, {
369 merged: [
370 ansi.syncStart,
371 ansi.cursorUp(1),
372 ansi.clearFullLine,
373 ansi.cursorUp(1),
374 ansi.clearFullLine,
375 ansi.cursorRestore,
376 ].join(""),
377 });
378 lock3.release("cursor-middle-of-line");
379 host.expectFrame(0, {
380 merged: [
381 ansi.cursorSave,
382 "\n",
383 "line one 0\nline two\n",
384 ansi.syncEnd,
385 ].join(""),
386 });
387 host.cancel();
388 });
389
390 test("draw lock middle of cursor restoration long", () => {
391 const host = new testing.MockScreen();
392
393 using _ = host.startWidget({
394 format: ({ now }) => `line one ${now}\nline two`,
395 });
396
397 host.expectFrame(0, {
398 stderr: testing.MockScreen.sync([
399 "line one 0\nline two\n",
400 ]),
401 });
402
403 host.writeOutput("info: line of log\n");
404 host.expectFrame(0, {
405 merged: testing.MockScreen.sync([
406 ansi.cursorUp(2),
407 ansi.clearFullLine,
408 "info: line of log\n",
409 "line one 0\nline two\n",
410 ]),
411 });
412
413 host.writeOutput("warn: line of log\n");
414 host.expectWithoutConsume(0);
415 const lock = host.getDrawLock("long");
416 // info: line of log
417 // line one 0
418 // line two
419 // [C]
420 host.expectFrame(null, {
421 merged: [
422 ansi.syncStart,
423 ansi.cursorUp(1),
424 ansi.clearFullLine,
425 ansi.cursorUp(1),
426 ansi.clearFullLine,
427 ansi.syncEnd,
428 "warn: line of log\n",
429 ].join(""),
430 stdout: "warn: line of log\n",
431 });
432 // info: line of log
433 // warn: line of log
434 // [C]
435 const lock2 = host.getDrawLock("long");
436 host.expectNone();
437 // now we will pretend the environment writeOutput "hello " without
438 // a newline. without extra writeOutput calls, it has to save and
439 // restore the cursor perfectly. when multiple locks are held,
440 // it's a bit ambiguous, so the implementation takes the slow path
441 // if any releases are uncertain of the terminal state.
442 lock2.release("cursor-middle-of-line");
443 host.expectNone();
444 lock.release("cursor-start-of-line");
445 // warn: line of log
446 // [c]
447 host.expectFrame(0, {
448 merged: [
449 ansi.cursorSave,
450 "\n",
451 ansi.syncStart,
452 "line one 0\nline two\n",
453 ansi.syncEnd,
454 ].join(""),
455 });
456 const lock3 = host.getDrawLock("long");
457 // hello [S]
458 // line one 0
459 // line two
460 // [C]
461 host.expectFrame(null, {
462 merged: [
463 ansi.syncStart,
464 ansi.cursorUp(1),
465 ansi.clearFullLine,
466 ansi.cursorUp(1),
467 ansi.clearFullLine,
468 ansi.syncEnd,
469 ansi.cursorRestore,
470 ].join(""),
471 });
472 lock3.release("cursor-middle-of-line");
473 host.expectFrame(0, {
474 merged: [
475 ansi.cursorSave,
476 "\n",
477 ansi.syncStart,
478 "line one 0\nline two\n",
479 ansi.syncEnd,
480 ].join(""),
481 });
482 host.cancel();
483 });
484
485 test("widget expires", () => {
486 const host = new testing.MockScreen();
487
488 host.writeOutput("log line 1\n");
489 host.writeOutput("log line 2\n");
490
491 using _ = host.startWidget({
492 format: ({ now }) => now > 1000 ? null : `widget line one ${now}\nwidget line two`,
493 fps: 1,
494 });
495
496 host.expectFrame(0, {
497 stdout: "log line 1\nlog line 2\n",
498 merged: testing.MockScreen.sync([
499 "log line 1\n",
500 "log line 2\n",
501 "widget line one 0\nwidget line two\n",
502 ]),
503 });
504 host.expectFrame(1000, {
505 merged: testing.MockScreen.sync([
506 ansi.cursorUp(2),
507 "widget line one 1000\nwidget line two\n",
508 ]),
509 });
510 host.expectFrame(1000, {
511 merged: testing.MockScreen.sync([
512 ansi.cursorUp(1),
513 ansi.clearFullLine,
514 ansi.cursorUp(1),
515 ansi.clearFullLine,
516 ]),
517 });
518 host.cancel();
519 });
520
521 test("widget expires partial line output", () => {
522 const host = new testing.MockScreen();
523
524 host.writeOutput("log line;");
525
526 let flag = true;
527 using _ = host.startWidget({
528 format: ({ now }) => flag ? `widget line one ${now}\nwidget line two` : null,
529 fps: 1,
530 });
531
532 host.expectFrame(0, {
533 stdout: "log line;",
534 merged: testing.MockScreen.sync([
535 "log line;",
536 "\n",
537 "widget line one 0\nwidget line two\n",
538 ]),
539 });
540 host.expectWithoutConsume(1000);
541 host.writeOutput(" rest of line\n");
542 flag = false;
543 host.expectFrame(0, {
544 stdout: " rest of line\n",
545 merged: testing.MockScreen.sync([
546 ansi.cursorUp(1),
547 ansi.clearFullLine,
548 ansi.cursorUp(1),
549 ansi.clearFullLine,
550 ansi.cursorUp(1) + ansi.cursorRight("log line;".length),
551 " rest of line\n",
552 ]),
553 });
554 host.cancel();
555 });
556
557 test("empty string keeps the widget alive", () => {
558 const host = new testing.MockScreen();
559 let text = "";
560 const w = UNWRAP(host.startWidget({ format: () => text }));
561 // an empty frame draws nothing, but the widget is not detached
562 host.expectFrame(0, { merged: "" });
563 text = "now!";
564 w.redraw();
565 host.expectFrame(0, {
566 stderr: testing.MockScreen.sync(["now!\n"]),
567 });
568 text = "";
569 w.redraw();
570 host.expectFrame(0, {
571 stderr: testing.MockScreen.sync([
572 ansi.cursorUp(1),
573 ansi.clearFullLine,
574 ]),
575 });
576 w.stop();
577 host.expectFrame(0, { merged: "" });
578 host.cancel();
579 });
580
581 test("leading blank line in widget output", () => {
582 const host = new testing.MockScreen();
583 using _ = host.startWidget({ format: () => "\nfoo" });
584 host.expectFrame(0, {
585 stderr: testing.MockScreen.sync([
586 "\n",
587 "foo\n",
588 ]),
589 });
590 host.cancel();
591 });
592
593 test("empty widget frame with a mid-line draw lock release", () => {
594 // regression: this combination used to crash an assertion, because an
595 // empty first widget line was treated as "no widgets on screen"
596 const host = new testing.MockScreen();
597 let text = "";
598 const w = UNWRAP(host.startWidget({ format: () => ({ text }) }));
599 host.expectFrame(0, { merged: "" });
600 const lock = host.getDrawLock("short");
601 lock.release("cursor-middle-of-line");
602 host.expectFrame(0, { merged: "" });
603 // the foreign partial line is respected once the widget renders
604 text = "hi";
605 w.redraw();
606 host.expectFrame(0, {
607 stderr: ansi.cursorSave + "\n" + testing.MockScreen.sync(["hi\n"]),
608 });
609 host.cancel();
610 });
611
612 test("temporary unlock is used when available", () => {
613 const host = new testing.MockScreen({ temporaryUnlocking: true });
614 using _ = host.startWidget({ format: () => "w" });
615 host.expectFrame(0, {
616 stderr: testing.MockScreen.sync(["w\n"]),
617 });
618 ASSERT(host.hasTerminalLock === "locked");
619 const lock = host.getDrawLock("short");
620 ASSERT(host.hasTerminalLock === "temporary-unlock");
621 host.expectFrame(null, {
622 stderr: ansi.syncStart + ansi.cursorUp(1) + ansi.clearFullLine,
623 });
624 lock.release("cursor-start-of-line");
625 ASSERT(host.hasTerminalLock === "locked");
626 host.expectFrame(0, {
627 stderr: "w\n" + ansi.syncEnd,
628 });
629 host.cancel();
630 });
631
632 test("cursor restore is not replayed for stale saves", () => {
633 const host = new testing.MockScreen();
634 using _ = host.startWidget({ format: () => "w" });
635 host.expectFrame(0, {
636 stderr: testing.MockScreen.sync(["w\n"]),
637 });
638
639 // a foreign partial line forces a cursor save
640 {
641 const lock = host.getDrawLock("short");
642 host.expectFrame(null, {
643 merged: ansi.syncStart + ansi.cursorUp(1) + ansi.clearFullLine,
644 });
645 lock.release("cursor-middle-of-line");
646 host.expectFrame(0, {
647 merged: ansi.cursorSave + "\n" + "w\n" + ansi.syncEnd,
648 });
649 }
650
651 // clearing the widget restores the saved cursor, exactly once
652 {
653 const lock = host.getDrawLock("short");
654 host.expectFrame(null, {
655 merged: ansi.syncStart + ansi.cursorUp(1) + ansi.clearFullLine
656 + ansi.cursorRestore,
657 });
658 lock.release("cursor-start-of-line");
659 host.expectFrame(0, {
660 merged: "w\n" + ansi.syncEnd,
661 });
662 }
663
664 // regression: the stale save register must not be restored again
665 {
666 const lock = host.getDrawLock("short");
667 host.expectFrame(null, {
668 merged: ansi.syncStart + ansi.cursorUp(1) + ansi.clearFullLine,
669 });
670 lock.release("cursor-start-of-line");
671 host.expectFrame(0, {
672 merged: "w\n" + ansi.syncEnd,
673 });
674 }
675 host.cancel();
676 });
677
678 test("log lines wider than the terminal", () => {
679 const host = new testing.MockScreen();
680 host.columns = 10;
681 using _ = host.startWidget({ format: () => "w1\nw2" });
682 host.expectFrame(0, {
683 stderr: testing.MockScreen.sync(["w1\nw2\n"]),
684 });
685 // a 15 cell line occupies two rows, so both widget rows get cleared
686 host.writeOutput("123456789012345\n");
687 host.expectFrame(0, {
688 merged: testing.MockScreen.sync([
689 ansi.cursorUp(1),
690 ansi.clearFullLine,
691 ansi.cursorUp(1),
692 ansi.clearFullLine,
693 "123456789012345\n",
694 "w1\n",
695 "w2\n",
696 ]),
697 });
698 host.cancel();
699 });
700
701 test("partial line wider than the terminal", () => {
702 const host = new testing.MockScreen();
703 host.columns = 10;
704 using _ = host.startWidget({ format: () => "w1\nw2" });
705 host.expectFrame(0, {
706 stderr: testing.MockScreen.sync(["w1\nw2\n"]),
707 });
708 // 14 cells: one wrapped row plus a partial row at column 4
709 host.writeOutput("12345678901234");
710 host.expectFrame(0, {
711 merged: testing.MockScreen.sync([
712 ansi.cursorUp(1),
713 ansi.clearFullLine,
714 ansi.cursorUp(1),
715 ansi.clearFullLine,
716 "12345678901234",
717 "\n",
718 "w1\n",
719 "w2\n",
720 ]),
721 });
722 // the continuation point is column 4 of the partial row, not column 14
723 host.writeOutput(" more\n");
724 host.expectFrame(0, {
725 merged: testing.MockScreen.sync([
726 ansi.cursorUp(3) + ansi.cursorRight(4),
727 " more\n",
728 "w1\n",
729 "w2\n",
730 ]),
731 });
732 host.cancel();
733 });
734
735 test("partial line with ansi codes measures visible width", () => {
736 const host = new testing.MockScreen();
737 using _ = host.startWidget({ format: () => "w" });
738 host.expectFrame(0, {
739 stderr: testing.MockScreen.sync(["w\n"]),
740 });
741 host.writeOutput(ansi.fgBlue + "blue" + ansi.fgReset);
742 host.expectFrame(0, {
743 merged: testing.MockScreen.sync([
744 ansi.cursorUp(1),
745 ansi.clearFullLine,
746 ansi.fgBlue + "blue" + ansi.fgReset,
747 "\n",
748 "w\n",
749 ]),
750 });
751 // "blue" is 4 cells wide; the escape sequences take none
752 host.writeOutput(" sky\n");
753 host.expectFrame(0, {
754 merged: testing.MockScreen.sync([
755 ansi.cursorUp(2) + ansi.cursorRight(4),
756 " sky\n",
757 "w\n",
758 ]),
759 });
760 host.cancel();
761 });
762
763 test("off-screen output does not move widgets", () => {
764 // models stdout redirected to a file while stderr stays the terminal:
765 // log output flushes to stdout, but the widget block is untouched by it
766 const host = new testing.MockScreen({ outputSharesScreen: false });
767 using _ = host.startWidget({ format: () => "w1\nw2" });
768 host.expectFrame(0, {
769 stderr: testing.MockScreen.sync(["w1\nw2\n"]),
770 });
771 host.writeOutput("a long log line that would have wrapped on screen\n");
772 host.expectFrame(0, {
773 stdout: "a long log line that would have wrapped on screen\n",
774 stderr: testing.MockScreen.sync([
775 ansi.cursorUp(2),
776 "w1\nw2\n",
777 ]),
778 });
779 // partial lines in the redirected stream do not shift the widgets either
780 host.writeOutput("partial");
781 host.expectFrame(0, {
782 stdout: "partial",
783 stderr: testing.MockScreen.sync([
784 ansi.cursorUp(2),
785 "w1\nw2\n",
786 ]),
787 });
788 host.cancel();
789 });
790
791 test("writeError routes to the interactive stream in order", () => {
792 const host = new testing.MockScreen();
793 host.writeOutput("out 1\n");
794 host.writeError("err 1\n");
795 host.writeOutput("out 2\n");
796 host.expectFrame(0, {
797 stdout: "out 1\nout 2\n",
798 stderr: "err 1\n",
799 merged: "out 1\nerr 1\nout 2\n",
800 });
801 host.cancel();
802 });
803
804 test("error text moves widgets like output", () => {
805 const host = new testing.MockScreen();
806 using _ = host.startWidget({ format: () => "w" });
807 host.expectFrame(0, { stderr: testing.MockScreen.sync(["w\n"]) });
808 host.writeError("oops\n");
809 host.expectFrame(0, {
810 stdout: "",
811 stderr: testing.MockScreen.sync([
812 ansi.cursorUp(1),
813 ansi.clearFullLine,
814 "oops\n",
815 "w\n",
816 ]),
817 });
818 host.cancel();
819 });
820
821 test("mixed stream partial lines share cursor math", () => {
822 const host = new testing.MockScreen();
823 using _ = host.startWidget({ format: () => "w" });
824 host.expectFrame(0, { stderr: testing.MockScreen.sync(["w\n"]) });
825 host.writeOutput("a");
826 host.writeError("b");
827 host.expectFrame(0, {
828 stdout: "a",
829 merged: testing.MockScreen.sync([
830 ansi.cursorUp(1),
831 ansi.clearFullLine,
832 "a",
833 "b",
834 "\n",
835 "w\n",
836 ]),
837 });
838 // the continuation point is column 2, covering both partial chunks
839 host.writeOutput("c\n");
840 host.expectFrame(0, {
841 merged: testing.MockScreen.sync([
842 ansi.cursorUp(2) + ansi.cursorRight(2),
843 "c\n",
844 "w\n",
845 ]),
846 });
847 host.cancel();
848 });
849
850 test("error text stays on screen when output is redirected", () => {
851 const host = new testing.MockScreen({ outputSharesScreen: false });
852 using _ = host.startWidget({ format: () => "w" });
853 host.expectFrame(0, { stderr: testing.MockScreen.sync(["w\n"]) });
854 host.writeOutput("to the file\n");
855 host.writeError("to the screen\n");
856 host.expectFrame(0, {
857 stdout: "to the file\n",
858 stderr: testing.MockScreen.sync([
859 ansi.cursorUp(1),
860 ansi.clearFullLine,
861 "to the screen\n",
862 "w\n",
863 ]),
864 });
865 host.cancel();
866 });
867
868 test("widgets start below a partial line", () => {
869 const host = new testing.MockScreen();
870 let text = "";
871 const w = UNWRAP(host.startWidget({ format: () => text }));
872 host.expectFrame(0, { merged: "" });
873 host.writeOutput("part");
874 host.expectFrame(0, { stdout: "part", merged: "part" });
875 text = "W";
876 w.redraw();
877 host.expectFrame(0, {
878 stderr: testing.MockScreen.sync(["\n", "W\n"]),
879 });
880 // the partial line can still be continued afterwards
881 host.writeOutput(" two\n");
882 host.expectFrame(0, {
883 merged: testing.MockScreen.sync([
884 ansi.cursorUp(2) + ansi.cursorRight(4),
885 " two\n",
886 "W\n",
887 ]),
888 });
889 host.cancel();
890 });
891
892 test("resize invalidates the widget area", () => {
893 const host = new testing.MockScreen();
894 using _ = host.startWidget({ format: () => "w1\nw2" });
895 host.expectFrame(0, {
896 stderr: testing.MockScreen.sync(["w1\nw2\n"]),
897 });
898 host.resize(40);
899 host.expectFrame(0, {
900 merged: ansi.cursorUp(2) + erase + testing.MockScreen.sync(["w1\nw2\n"]),
901 });
902 host.cancel();
903 });
904
905 test("a narrower terminal wraps what was drawn, which is still found whole", () => {
906 // a widget as wide as the terminal, as with lines ruled across it
907 const rule = (ctx: log.WidgetFormatContext) => "-".repeat(ctx.width - 1);
908 const cursors = [undefined, { line: 1, column: 13 }, { line: 0, column: 70 }];
909 const sizes = [[100, 90], [100, 40], [100, 14], [40, 100]] as const;
910 for (const xtermjs of [false, true]) {
911 for (const cursor of cursors) {
912 for (const [from, to] of sizes) {
913 const host = new testing.MockScreen();
914 host.terminal.xtermjs = xtermjs;
915 host.resize(from);
916 host.writeOutput("log line\n");
917 const w = host.startWidget({
918 format: (ctx) => ({ text: `${rule(ctx)}\n$ state.ticks\n${rule(ctx)}`, cursor }),
919 input() {},
920 });
921 host.advance();
922 // xterm.js is known by what it answers when asked for its version
923 if (xtermjs) host.type("\x1b[>0;276;0c");
924 host.resize(to);
925 host.advance();
926 const line = "-".repeat(to - 1);
927 assert.equal(host.terminal.text, `log line\n${line}\n$ state.ticks\n${line}`);
928 w?.stop();
929 host.advance();
930 assert.equal(host.terminal.text, "log line");
931 host.cancel();
932 }
933 }
934 }
935 });
936
937 test("what is drawn before a resize is heard of does not wrap", () => {
938 const rule = (ctx: log.WidgetFormatContext) => "-".repeat(ctx.width - 1);
939 for (const xtermjs of [false, true]) {
940 const host = new testing.MockScreen();
941 host.terminal.xtermjs = xtermjs;
942 host.resize(60);
943 host.writeOutput("log line\n");
944 let ticks = 0;
945 using w = UNWRAP(host.startWidget({
946 format: (ctx) => ({
947 text: `${rule(ctx)}\n$ ${ticks}\n${rule(ctx)}\nstatus`,
948 cursor: { line: 1, column: 3 },
949 }),
950 input() {},
951 }));
952 host.advance();
953 if (xtermjs) host.type("\x1b[>0;276;0c");
954
955 // the screen has its new size before the pty behind it does
956 host.terminal.resize(40, 24);
957 for (ticks = 1; ticks <= 3; ticks += 1) {
958 w.redraw();
959 host.advance();
960 }
961 host.resize(40);
962 host.advance();
963 const line = "-".repeat(39);
964 assert.equal(host.terminal.text, `log line\n${line}\n$ 4\n${line}\nstatus`);
965 host.cancel();
966 }
967 });
968
969 test("a row that held a longer line is not wrapped as that line", () => {
970 const host = new testing.MockScreen();
971 host.resize(60);
972 host.writeOutput("log line\n");
973 let rows = ["-".repeat(59), "$ "];
974 using w = UNWRAP(host.startWidget({ format: () => rows.join("\n") }));
975 host.advance();
976 // each line moves down a row, the short one into the row of the long one
977 rows = ["status", ...rows];
978 w.redraw();
979 host.advance();
980 host.resize(40);
981 host.advance();
982 assert.equal(host.terminal.text, `log line\nstatus\n${"-".repeat(39)}\n$`);
983 host.cancel();
984 });
985
986 test("widget redraw during another widget's render", () => {
987 const host = new testing.MockScreen();
988 let kicked = false;
989 const b = UNWRAP(host.startWidget({ format: () => "b" }));
990 using _ = host.startWidget({
991 format: () => {
992 if (!kicked) {
993 kicked = true;
994 b.redraw();
995 }
996 return "a";
997 },
998 });
999 host.expectFrame(0, {
1000 stderr: testing.MockScreen.sync(["b\na\n"]),
1001 });
1002 // the request from inside the render pass produces a follow-up frame
1003 host.expectFrame(0, {
1004 stderr: testing.MockScreen.sync([
1005 ansi.cursorUp(2),
1006 "b\na\n",
1007 ]),
1008 });
1009 host.expectNone();
1010 host.cancel();
1011 });
1012
1013 test("widget stopped during another widget's render", () => {
1014 const host = new testing.MockScreen();
1015 let b: log.WidgetInstance | null = null;
1016 using _ = host.startWidget({
1017 format: () => {
1018 b?.stop();
1019 b = null;
1020 return "a";
1021 },
1022 });
1023 b = host.startWidget({ format: () => "b" });
1024 host.expectFrame(0, {
1025 stderr: testing.MockScreen.sync(["a\n"]),
1026 });
1027 // the stop request also schedules a follow-up frame; it draws no change
1028 host.expectFrame(0, {
1029 stderr: testing.MockScreen.sync([
1030 ansi.cursorUp(1),
1031 "a\n",
1032 ]),
1033 });
1034 host.expectNone();
1035 host.cancel();
1036 });
1037
1038 test("fps setter schedules frames", () => {
1039 const host = new testing.MockScreen();
1040 const w = UNWRAP(host.startWidget({ format: ({ now }) => `t=${now}` }));
1041 host.expectFrame(0, {
1042 stderr: testing.MockScreen.sync(["t=0\n"]),
1043 });
1044 host.expectNone();
1045 w.fps = 1;
1046 host.expectFrame(0, {
1047 stderr: testing.MockScreen.sync([
1048 ansi.cursorUp(1),
1049 "t=0\n",
1050 ]),
1051 });
1052 host.expectWithoutConsume(1000);
1053 w.fps = null;
1054 host.expectFrame(0, {
1055 stderr: testing.MockScreen.sync([
1056 ansi.cursorUp(1),
1057 "t=0\n",
1058 ]),
1059 });
1060 host.expectNone();
1061 w.stop();
1062 host.expectFrame(0, {
1063 stderr: testing.MockScreen.sync([
1064 ansi.cursorUp(1),
1065 ansi.clearFullLine,
1066 ]),
1067 });
1068 host.cancel();
1069 });
1070
1071 test("oversized buffers flush whole lines synchronously", () => {
1072 const host = new testing.MockScreen();
1073 const line = "x".repeat(8191) + "\n";
1074 for (let i = 0; i < 7; i += 1) host.writeOutput(line);
1075 ASSERT(host.stdout === "", "flushed before reaching the threshold");
1076 // crossing the threshold flushes immediately, but the trailing line
1077 // under construction stays buffered
1078 host.writeOutput(line + "partial");
1079 ASSERT(host.stdout === line.repeat(8), "whole lines were not flushed");
1080 host.expectFrame(0, { stdout: line.repeat(8) + "partial" });
1081 host.cancel();
1082 });
1083
1084 test("single line larger than the flush threshold still flushes", () => {
1085 const host = new testing.MockScreen();
1086 const giant = "y".repeat(70000);
1087 host.writeOutput(giant);
1088 // no newline is buffered, so memory is bounded by flushing mid-line
1089 host.expectFrame(null, { stdout: giant });
1090 host.cancel();
1091 });
1092
1093 test("starved redraw timer flushes on the next write", () => {
1094 const host = new testing.MockScreen();
1095 host.writeOutput("one\n");
1096 host.expectWithoutConsume(0);
1097 // simulate a blocked event loop: time advances but no timer fires
1098 host.timers.time += 30;
1099 host.writeOutput("two\n");
1100 ASSERT(host.stdout === "", "flushed before the starvation threshold");
1101 host.timers.time += 30;
1102 host.writeOutput("three\n");
1103 host.expectFrame(null, { stdout: "one\ntwo\nthree\n" });
1104 host.cancel();
1105 });
1106
1107 test("node host patches and restores std streams", async () => {
1108 const proc = UNWRAP(node.process);
1109 const origOut = proc.stdout.write;
1110 const origErr = proc.stderr.write;
1111 let stdoutData = "";
1112 let stderrData = "";
1113 proc.stdout.write = (chunk) => (stdoutData += chunk, true);
1114 proc.stderr.write = (chunk) => (stderrData += chunk, true);
1115 const myOut = proc.stdout.write;
1116 const myErr = proc.stderr.write;
1117 try {
1118 const host = log.defaultNodeProcessWidgetHost(proc, true);
1119 for (let i = 0; i < 3; i += 1) {
1120 host.writeOutput(`line ${i}\n`);
1121 await new Promise((resolve) => setTimeout(resolve, 2));
1122 }
1123 host.cancel();
1124 // regression: the unpatch used to clobber stdout with stderr's methods
1125 // and leave a permanently growing wrapper chain on stderr
1126 ASSERT(proc.stdout.write === myOut, "stdout.write was not restored");
1127 ASSERT(proc.stderr.write === myErr, "stderr.write was not restored");
1128 // log output lands on real stdout, so it stays redirectable
1129 ASSERT(stdoutData.includes("line 0\n"));
1130 ASSERT(stdoutData.includes("line 2\n"));
1131 ASSERT(stderrData === "");
1132 } finally {
1133 proc.stdout.write = origOut;
1134 proc.stderr.write = origErr;
1135 }
1136 });
1137
1138 test("writing during a render function is OK", () => {
1139 const host = new testing.MockScreen();
1140
1141 using w = host.startWidget({
1142 format: ({ now }) => {
1143 host.writeOutput("log line\n");
1144 return "widget line";
1145 },
1146 fps: 1,
1147 });
1148
1149 host.expectFrame(0, {
1150 stdout: "log line\n",
1151 merged: testing.MockScreen.sync([
1152 "log line\n",
1153 "widget line\n",
1154 ]),
1155 });
1156 host.expectFrame(1000, {
1157 stdout: "log line\n",
1158 merged: testing.MockScreen.sync([
1159 ansi.cursorUp(1),
1160 ansi.clearFullLine,
1161 "log line\n",
1162 "widget line\n",
1163 ]),
1164 });
1165 w?.stop();
1166 host.expectFrame(0, {
1167 // stdout: "log line\n",
1168 merged: testing.MockScreen.sync([
1169 ansi.cursorUp(1),
1170 ansi.clearFullLine,
1171 ]),
1172 });
1173 host.cancel();
1174 });
1175});
1176
1177describe("widget height", () => {
1178 test("a widget that shrinks as logs are written leaves nothing behind", () => {
1179 for (const written of ["log\n", "log 1\nlog 2\n", "partial", "a\nb\nc\nd\ne\nf\n"]) {
1180 const host = new testing.MockScreen();
1181 let text = "one\ntwo\nthree\nfour\nfive";
1182 const w = host.startWidget({ format: () => text });
1183 host.advance();
1184 text = "only";
1185 host.writeOutput(written);
1186 w?.redraw();
1187 host.advance();
1188 assert.equal(host.terminal.text, written.trimEnd() + "\nonly");
1189 w?.stop();
1190 host.advance();
1191 assert.equal(host.terminal.text, written.trimEnd());
1192 host.cancel();
1193 }
1194 });
1195});
1196
1197describe("widget input", () => {
1198 // what the terminal is told around reading the keyboard: bracketed paste,
1199 // and the kitty keyboard protocol once it answers to having it
1200 const paste = "\x1b[?2004h";
1201 const unpaste = "\x1b[?2004l";
1202 const enhance = "\x1b[>1u";
1203 const unenhance = "\x1b[<u";
1204 const reading = paste + "\x1b[?u\x1b[>c";
1205 /** what a widget draws while one has the keyboard: rows that cannot wrap */
1206 const rows = (text: string) => "\x1b[?7l" + text + "\x1b[?7h";
1207
1208 /** what a widget receives for chunks of terminal input, once time has passed */
1209 function decode(...chunks: string[]): log.WidgetInput[] {
1210 const host = new testing.MockScreen();
1211 const events: log.WidgetInput[] = [];
1212 const w = host.startWidget({ format: () => "", input: (event) => events.push(event) });
1213 host.advance();
1214 for (const chunk of chunks) host.type(chunk);
1215 host.advance(50);
1216 w?.stop();
1217 host.advance();
1218 host.cancel();
1219 return events;
1220 }
1221
1222 function keys(...chunks: string[]): string[] {
1223 return decode(...chunks).map((event) => event.key);
1224 }
1225
1226 test("printable text is one event per grapheme", () => {
1227 assert.deepEqual(decode("aA "), [
1228 { key: "a", text: "a" },
1229 { key: "A", text: "A" },
1230 { key: "space", text: " " },
1231 ]);
1232 assert.deepEqual(decode("e\u{301}👩‍👩‍👧語"), [
1233 { key: "e\u{301}", text: "e\u{301}" },
1234 { key: "👩‍👩‍👧", text: "👩‍👩‍👧" },
1235 { key: "語", text: "語" },
1236 ]);
1237 });
1238
1239 test("control bytes", () => {
1240 assert.deepEqual(decode("\r\t"), [
1241 { key: "enter", text: "\n" },
1242 { key: "tab", text: "\t" },
1243 ]);
1244 assert.deepEqual(decode("\x03"), [{ key: "ctrl+c", text: "" }]);
1245 assert.deepEqual(keys("\n\x08\x7f\x00\x1c\x1f\x9b"), [
1246 "ctrl+j",
1247 "ctrl+h",
1248 "backspace",
1249 "ctrl+space",
1250 "ctrl+\\",
1251 "ctrl+_",
1252 ]);
1253 });
1254
1255 test("legacy escape sequences", () => {
1256 assert.deepEqual(keys("\x1b[A\x1b[1;5C\x1b[1;2D\x1bOH\x1b[F"), [
1257 "up",
1258 "ctrl+right",
1259 "shift+left",
1260 "home",
1261 "end",
1262 ]);
1263 assert.deepEqual(keys("\x1b[3~\x1b[3;5~\x1b[5~"), ["delete", "ctrl+delete", "pageup"]);
1264 // going backwards is not a tab to type
1265 assert.deepEqual(decode("\x1b[Z\x1b[9;2u"), [
1266 { key: "shift+tab", text: "" },
1267 { key: "shift+tab", text: "" },
1268 ]);
1269 assert.deepEqual(keys("\x1bOP\x1b[15~\x1b[1;3R\x1b[24;2~"), [
1270 "f1",
1271 "f5",
1272 "alt+f3",
1273 "shift+f12",
1274 ]);
1275 // the linux console, and modifiers after SS3
1276 assert.deepEqual(keys("\x1b[[A\x1b[[Ex\x1bO5P\x1bO1;2A"), [
1277 "f1",
1278 "f5",
1279 "x",
1280 "ctrl+f1",
1281 "shift+up",
1282 ]);
1283 assert.deepEqual(keys("ab\x1b[Ac"), ["a", "b", "up", "c"]);
1284 });
1285
1286 test("an escape prefix is alt", () => {
1287 assert.deepEqual(decode("\x1bf"), [{ key: "alt+f", text: "" }]);
1288 assert.deepEqual(keys("\x1bF\x1b\x7f\x1b\r\x1b\x1b[A\x1b\x01\x1b."), [
1289 "alt+shift+f",
1290 "alt+backspace",
1291 "alt+enter",
1292 "alt+up",
1293 "ctrl+alt+a",
1294 "alt+.",
1295 ]);
1296 // however long the run of prefixes
1297 assert.deepEqual(keys("\x1b".repeat(20000) + "[A"), ["alt+up"]);
1298 });
1299
1300 test("kitty keyboard protocol", () => {
1301 assert.deepEqual(decode("\x1b[13;2u"), [{ key: "shift+enter", text: "\n" }]);
1302 assert.deepEqual(keys("\x1b[99;5u\x1b[27u\x1b[97;6u\x1b[97;4u\x1b[127;5u"), [
1303 "ctrl+c",
1304 "escape",
1305 "ctrl+shift+a",
1306 "alt+shift+a",
1307 "ctrl+backspace",
1308 ]);
1309 assert.deepEqual(keys("\x1b[57414u\x1b[57376u\x1b[57420;5u"), ["enter", "f13", "ctrl+down"]);
1310 // shift on its own is folded into the letter
1311 assert.deepEqual(decode("\x1b[97;2u"), [{ key: "A", text: "A" }]);
1312 // the shifted key and the text can be reported by the terminal
1313 assert.deepEqual(decode("\x1b[49:33;2u"), [{ key: "!", text: "!" }]);
1314 assert.deepEqual(decode("\x1b[97;;228u"), [{ key: "a", text: "ä" }]);
1315 // lock keys are not modifiers, and releases are not presses
1316 assert.deepEqual(keys("\x1b[97;65u\x1b[97;129u"), ["a", "a"]);
1317 assert.deepEqual(keys("\x1b[97;1:3u\x1b[1;1:3A"), []);
1318 // keys without a name are dropped, as are numbers that are no character
1319 assert.deepEqual(keys("\x1b[57358u"), []);
1320 assert.deepEqual(keys("\x1b[1114112u\x1b[27;5;99999999~\x1b[97:99999999;2u"), ["A"]);
1321 assert.deepEqual(decode("\x1b[97;;99999999u"), [{ key: "a", text: "a" }]);
1322 // xterm's modifyOtherKeys says the same another way
1323 assert.deepEqual(keys("\x1b[27;2;13~\x1b[27;5;97~"), ["shift+enter", "ctrl+a"]);
1324 });
1325
1326 test("bracketed paste", () => {
1327 assert.deepEqual(decode("\x1b[200~one\r\ntwo\rthree\x1b[A\x1b[201~x"), [
1328 { key: "paste", text: "one\ntwo\nthree\x1b[A" },
1329 { key: "x", text: "x" },
1330 ]);
1331 // split across reads, even inside of its terminator
1332 assert.deepEqual(decode("\x1b[200~abc\x1b[2", "0", "1~x"), [
1333 { key: "paste", text: "abc" },
1334 { key: "x", text: "x" },
1335 ]);
1336 assert.deepEqual(decode("\x1b[200~a\x1b[2", "K\x1b[201~"), [
1337 { key: "paste", text: "a\x1b[2K" },
1338 ]);
1339 });
1340
1341 test("a paste has no deadline, unlike an escape sequence", () => {
1342 const host = new testing.MockScreen();
1343 const seen: string[] = [];
1344 const w = host.startWidget({ format: () => "", input: (event) => seen.push(event.key) });
1345 host.advance();
1346 host.type("\x1b[200~abc\x1b[2");
1347 host.advance(1000);
1348 host.type("01~");
1349 assert.deepEqual(seen, ["paste"]);
1350 // a sequence is put together across reads that come in time
1351 host.type("\x1b");
1352 host.type("[1;");
1353 host.type("5A");
1354 assert.deepEqual(seen, ["paste", "ctrl+up"]);
1355 w?.stop();
1356 host.advance();
1357 host.cancel();
1358 });
1359
1360 test("a pause resolves what is ambiguous", () => {
1361 assert.deepEqual(keys("\x1b"), ["escape"]);
1362 assert.deepEqual(keys("\x1b["), ["alt+["]);
1363 assert.deepEqual(keys("\x1bO"), ["alt+shift+o"]);
1364 // a held escape key repeats without anything in between
1365 assert.deepEqual(keys("\x1b", "\x1b", "\x1b"), ["escape", "escape", "escape"]);
1366 // a sequence that was cut off is not typed out
1367 assert.deepEqual(keys("\x1b[1;5"), []);
1368 });
1369
1370 test("sequences that are not keys", () => {
1371 // device attributes, focus in, color reports, a cursor position
1372 assert.deepEqual(keys("\x1b[?62;4c\x1b[I\x1b]11;rgb:0/0/0\x07\x1b]11;x\x1b\\\x1b[4;2R"), []);
1373 // without a terminator in sight, the introducer is the alt key
1374 assert.deepEqual(keys("\x1b]x"), ["alt+]", "x"]);
1375 // a malformed sequence is abandoned at the offending byte
1376 assert.deepEqual(keys("\x1b[1\x1b[A"), ["up"]);
1377 });
1378
1379 test("the keyboard goes to the most recently started input widget", () => {
1380 const host = new testing.MockScreen();
1381 const seen: string[] = [];
1382 const plain = host.startWidget({ format: () => "status" });
1383 host.expectFrame(0, { stderr: testing.MockScreen.sync(["status\n"]) });
1384 // a widget without `input` leaves the terminal alone
1385 ASSERT(!host.reading);
1386
1387 const a = host.startWidget({
1388 format: () => "a",
1389 input: (event) => seen.push("a:" + event.key),
1390 });
1391 host.expectFrame(0, {
1392 stderr: reading + testing.MockScreen.sync([ansi.cursorUp(1), rows(ansi.clearFullLine + "a\nstatus\n")]),
1393 });
1394 ASSERT(host.reading);
1395 host.type("x\x1b[A");
1396 ASSERT(seen.join() === "a:x,a:up");
1397
1398 const b = host.startWidget({
1399 format: () => "b",
1400 input: (event) => seen.push("b:" + event.key),
1401 });
1402 host.type("y");
1403 b?.stop();
1404 host.type("z");
1405 ASSERT(seen.join() === "a:x,a:up,b:y,a:z");
1406 host.advance();
1407 ASSERT(host.reading);
1408
1409 a?.stop();
1410 host.expectFrame(0, {
1411 stderr: unpaste + testing.MockScreen.sync([
1412 ansi.cursorUp(1) + ansi.clearToEndOfLine,
1413 ansi.cursorUp(1),
1414 "status\n",
1415 ]),
1416 });
1417 ASSERT(!host.reading);
1418 plain?.stop();
1419 host.advance();
1420 host.cancel();
1421 });
1422
1423 test("a widget places the cursor inside itself", () => {
1424 const host = new testing.MockScreen();
1425 let column = 5;
1426 const w = host.startWidget({
1427 format: () => ({ text: "> abc\nmenu", cursor: { line: 0, column } }),
1428 input() {},
1429 });
1430 host.expectFrame(0, {
1431 stderr: reading + testing.MockScreen.sync([
1432 rows("> abc\nmenu\n"),
1433 ansi.cursorUp(2) + ansi.cursorRight(5),
1434 ]),
1435 });
1436 assert.deepEqual(host.terminal.cursor, { line: 0, column: 5 });
1437
1438 // every later write first returns the cursor to its resting place
1439 column = 2;
1440 w?.redraw();
1441 host.expectFrame(0, {
1442 stderr: testing.MockScreen.sync([
1443 ansi.cursorDown(2) + "\r",
1444 ansi.cursorUp(2),
1445 rows("> abc\nmenu\n"),
1446 ansi.cursorUp(2) + ansi.cursorRight(2),
1447 ]),
1448 });
1449
1450 host.writeOutput("log line\n");
1451 host.advance();
1452 assert.equal(host.terminal.text, "log line\n> abc\nmenu");
1453 assert.deepEqual(host.terminal.cursor, { line: 1, column: 2 });
1454
1455 // foreign writes take the draw lock, which finds the cursor parked
1456 const lock = host.getDrawLock("short");
1457 host.terminal.write("foreign\n");
1458 lock.release("cursor-start-of-line");
1459 host.advance();
1460 assert.equal(host.terminal.text, "log line\nforeign\n> abc\nmenu");
1461 assert.deepEqual(host.terminal.cursor, { line: 2, column: 2 });
1462
1463 w?.stop();
1464 host.advance();
1465 assert.equal(host.terminal.text, "log line\nforeign");
1466 assert.deepEqual(host.terminal.cursor, { line: 2, column: 0 });
1467 host.cancel();
1468 });
1469
1470 test("the widget with the keyboard comes first", () => {
1471 const host = new testing.MockScreen();
1472 host.resize(80, 8);
1473 const cursor = { line: 2, column: 1 };
1474 const a = host.startWidget({ format: () => ({ text: "x\ny\nz", cursor }), input() {} });
1475 const b = host.startWidget({ format: () => ({ text: "one\ntwo", cursor }) });
1476 host.advance();
1477 // it is drawn above the others, whenever they were started, and it is
1478 // the only one to place the cursor
1479 assert.equal(host.terminal.text, "x\ny\nz\none\ntwo");
1480 assert.deepEqual(host.terminal.cursor, { line: 2, column: 1 });
1481
1482 // when there are too few rows, it is the others that go without
1483 host.resize(80, 5);
1484 host.advance();
1485 assert.equal(host.terminal.text, "x\ny\nz");
1486 assert.deepEqual(host.terminal.cursor, { line: 2, column: 1 });
1487 // a cursor on a line that got cut off is not honored
1488 host.resize(80, 3);
1489 host.advance();
1490 assert.equal(host.terminal.text, "x\ny");
1491 assert.deepEqual(host.terminal.cursor, { line: 2, column: 0 });
1492 a?.stop();
1493 b?.stop();
1494 host.advance();
1495 host.cancel();
1496 });
1497
1498 test("a long draw lock hands back the keyboard", () => {
1499 const host = new testing.MockScreen({ temporaryUnlocking: true });
1500 const w = host.startWidget({ format: () => "prompt", input() {} });
1501 host.advance();
1502 const short = host.getDrawLock("short");
1503 ASSERT(host.reading);
1504 short.release("cursor-start-of-line");
1505 host.advance();
1506
1507 const lock = host.getDrawLock("long");
1508 host.expectFrame(null, {
1509 stderr: testing.MockScreen.sync([ansi.cursorUp(1) + ansi.clearFullLine])
1510 + unpaste,
1511 });
1512 ASSERT(!host.reading);
1513 lock.release("cursor-start-of-line");
1514 // the question about the keyboard protocol is not asked twice
1515 host.expectFrame(0, {
1516 stderr: paste + testing.MockScreen.sync([rows("prompt\n")]),
1517 });
1518 ASSERT(host.reading);
1519 w?.stop();
1520 host.advance();
1521 ASSERT(!host.reading);
1522 host.cancel();
1523 });
1524
1525 test("the keyboard is let go when a draw lock outlives the widget", () => {
1526 for (const temporaryUnlocking of [true, false]) {
1527 const host = new testing.MockScreen({ temporaryUnlocking });
1528 const w = host.startWidget({ format: () => "prompt", input() {} });
1529 host.advance();
1530 // as when a prompt resolves and its result is logged right away
1531 w?.stop();
1532 host.getDrawLock("short").release("cursor-start-of-line");
1533 host.advance();
1534 ASSERT(!host.reading);
1535 ASSERT(host.hasTerminalLock === null);
1536 assert.equal(host.terminal.text, "");
1537 host.cancel();
1538 }
1539 });
1540
1541 test("a resize finds the widget from where the cursor is", () => {
1542 const host = new testing.MockScreen();
1543 const w = host.startWidget({
1544 format: () => ({ text: "one\ntwo\nthree", cursor: { line: 1, column: 2 } }),
1545 input() {},
1546 });
1547 host.advance();
1548 host.resize(80, 20);
1549 // a shorter terminal may have dropped the rows below the cursor, so it
1550 // is not moved down through them first
1551 host.expectFrame(0, {
1552 stderr: ansi.cursorUp(1) + erase + testing.MockScreen.sync([
1553 rows("one\ntwo\nthree\n"),
1554 ansi.cursorUp(2) + ansi.cursorRight(2),
1555 ]),
1556 });
1557 w?.stop();
1558 host.advance();
1559 host.cancel();
1560 });
1561
1562 test("a cursor that is not a cell is not placed", () => {
1563 const host = new testing.MockScreen();
1564 let cursor = { line: -1, column: 0 };
1565 const w = host.startWidget({ format: () => ({ text: "prompt", cursor }), input() {} });
1566 host.advance();
1567 assert.deepEqual(host.terminal.cursor, { line: 1, column: 0 });
1568 cursor = { line: 0, column: -3.5 };
1569 w?.redraw();
1570 host.advance();
1571 assert.deepEqual(host.terminal.cursor, { line: 0, column: 0 });
1572 w?.stop();
1573 host.advance();
1574 host.cancel();
1575 });
1576
1577 test("a lone escape byte is the escape key once time passes", () => {
1578 const host = new testing.MockScreen();
1579 const seen: string[] = [];
1580 const w = host.startWidget({
1581 format: () => "",
1582 input: (event) => seen.push(event.key),
1583 });
1584 host.advance();
1585 host.type("\x1b");
1586 ASSERT(seen.length === 0);
1587 host.type("[A");
1588 host.type("\x1b");
1589 host.advance(49);
1590 assert.deepEqual(seen, ["up"]);
1591 host.advance(1);
1592 assert.deepEqual(seen, ["up", "escape"]);
1593 w?.stop();
1594 host.advance();
1595 host.cancel();
1596 });
1597
1598 test("the keyboard protocol is enabled once the terminal answers", () => {
1599 const host = new testing.MockScreen();
1600 const seen: string[] = [];
1601 const options = { format: () => "", input: (event: log.WidgetInput) => seen.push(event.key) };
1602 let w = host.startWidget(options);
1603 host.expectFrame(0, { stderr: reading });
1604 host.type("\x1b[?0u\x1b[13;2u");
1605 host.expectFrame(null, { stderr: enhance });
1606 assert.deepEqual(seen, ["shift+enter"]);
1607 w?.stop();
1608 host.expectFrame(0, { stderr: unenhance + unpaste });
1609
1610 // the answer is remembered, so it is not asked for again
1611 w = host.startWidget(options);
1612 host.expectFrame(0, { stderr: paste + enhance });
1613 w?.stop();
1614 host.advance();
1615 host.cancel();
1616 });
1617});
1618
1619import assert from "node:assert/strict";
1620import { describe, test } from "vitest";
1621import { ASSERT, UNWRAP } from "./assert.ts";
1622import * as log from "./log.ts";
1623import * as node from "./node.ts";
1624import * as ansi from "./string/ansi.ts";
1625import * as testing from "./testing.ts";