@tuidom/testing 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/FakeTerminalSurface.d.ts +71 -0
- package/dist/FakeTerminalSurface.js +122 -0
- package/dist/TestApp.d.ts +21 -0
- package/dist/TestApp.js +58 -0
- package/dist/darkPlusStyleVars.d.ts +1 -0
- package/dist/darkPlusStyleVars.js +128 -0
- package/dist/expectScreen.d.ts +25 -0
- package/dist/expectScreen.js +64 -0
- package/dist/mockTerminalBackend.d.ts +97 -0
- package/dist/mockTerminalBackend.js +229 -0
- package/dist/perfFixtures.d.ts +38 -0
- package/dist/perfFixtures.js +97 -0
- package/dist/renderElement.d.ts +24 -0
- package/dist/renderElement.js +33 -0
- package/dist/storyTypes.d.ts +17 -0
- package/dist/storyTypes.js +1 -0
- package/package.json +34 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { IDisposable } from "@tuidom/core/common/disposable";
|
|
2
|
+
import type { ITerminalSurface, TerminalCell, TerminalMouseEventData } from "@tuidom/core/common/iTerminalSurface";
|
|
3
|
+
export interface FakeCellOptions {
|
|
4
|
+
fg?: number;
|
|
5
|
+
bg?: number;
|
|
6
|
+
style?: number;
|
|
7
|
+
width?: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Скриптованная реализация {@link ITerminalSurface} для тестов `TerminalViewElement`.
|
|
11
|
+
* Сетку задаём построчно (`setGrid`) или поячеечно (`setCell` — для цветов/стилей/wide-char),
|
|
12
|
+
* курсор — `setCursor`, флаг выхода — `isExited`. Все обращения виджета наружу
|
|
13
|
+
* (`write`/`sendMouse`/`resize`) пишутся в публичные массивы для ассертов, а `onUpdate`/
|
|
14
|
+
* `onExit` дёргаются вручную через `emitUpdate`/`emitExit`.
|
|
15
|
+
*
|
|
16
|
+
* Прокрутка смоделирована состоянием, а не историей: `scrollbackLines` задаёт потолок
|
|
17
|
+
* для `scrollLines`, а `scrollOffset` можно проверять напрямую. Сетка — это всегда
|
|
18
|
+
* вьюпорт как он есть, смещение её не двигает (настоящий сдвиг строк проверяется на
|
|
19
|
+
* `EmbeddedTerminalSession`).
|
|
20
|
+
*/
|
|
21
|
+
export declare class FakeTerminalSurface implements ITerminalSurface, IDisposable {
|
|
22
|
+
private grid;
|
|
23
|
+
private cursor;
|
|
24
|
+
private readonly updateListeners;
|
|
25
|
+
private readonly exitListeners;
|
|
26
|
+
isExited: boolean;
|
|
27
|
+
/** Включила ли «программа в шелле» mouse-tracking — от этого зависит судьба колеса. */
|
|
28
|
+
mouseEventsActive: boolean;
|
|
29
|
+
/** Сколько строк истории лежит выше вьюпорта: потолок для `scrollLines`. */
|
|
30
|
+
scrollbackLines: number;
|
|
31
|
+
scrollOffset: number;
|
|
32
|
+
/** Стал ли фейк «убитым» — контроллер обязан звать dispose() при закрытии терминала. */
|
|
33
|
+
disposed: boolean;
|
|
34
|
+
readonly writes: string[];
|
|
35
|
+
readonly mouseEvents: TerminalMouseEventData[];
|
|
36
|
+
readonly resizes: {
|
|
37
|
+
cols: number;
|
|
38
|
+
rows: number;
|
|
39
|
+
}[];
|
|
40
|
+
/** Заполнить сетку строками текста (каждый символ — обычная ячейка ширины 1). */
|
|
41
|
+
setGrid(lines: string[]): void;
|
|
42
|
+
/**
|
|
43
|
+
* Задать одну ячейку — здесь навешиваем цвета/стиль/ширину. При `width === 2`
|
|
44
|
+
* правый сосед (`x + 1`) помечается как continuation (readCell вернёт там false).
|
|
45
|
+
*/
|
|
46
|
+
setCell(x: number, y: number, char: string, options?: FakeCellOptions): void;
|
|
47
|
+
setCursor(cursor: {
|
|
48
|
+
x: number;
|
|
49
|
+
y: number;
|
|
50
|
+
} | null): void;
|
|
51
|
+
/** Дёрнуть подписчиков onUpdate (эмуляция «пришли новые данные из шелла»). */
|
|
52
|
+
emitUpdate(): void;
|
|
53
|
+
/** Дёрнуть подписчиков onExit (эмуляция выхода шелла). */
|
|
54
|
+
emitExit(exitCode: number): void;
|
|
55
|
+
readCell(x: number, y: number, out: TerminalCell): boolean;
|
|
56
|
+
getCursor(): {
|
|
57
|
+
x: number;
|
|
58
|
+
y: number;
|
|
59
|
+
} | null;
|
|
60
|
+
write(data: string): void;
|
|
61
|
+
/** Прокрутка вьюпорта: `delta < 0` — вверх, в историю; клампится в [0, scrollbackLines]. */
|
|
62
|
+
scrollLines(delta: number): void;
|
|
63
|
+
sendMouse(event: TerminalMouseEventData): void;
|
|
64
|
+
resize(cols: number, rows: number): void;
|
|
65
|
+
onUpdate(cb: () => void): IDisposable;
|
|
66
|
+
onExit(cb: (exitCode: number) => void): IDisposable;
|
|
67
|
+
/** Помечает фейк убитым (в реале — kill PTY + dispose эмулятора). */
|
|
68
|
+
dispose(): void;
|
|
69
|
+
private setScrollOffset;
|
|
70
|
+
private ensureRow;
|
|
71
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { DEFAULT_COLOR } from "@tuidom/core/common/colorUtils";
|
|
2
|
+
/**
|
|
3
|
+
* Скриптованная реализация {@link ITerminalSurface} для тестов `TerminalViewElement`.
|
|
4
|
+
* Сетку задаём построчно (`setGrid`) или поячеечно (`setCell` — для цветов/стилей/wide-char),
|
|
5
|
+
* курсор — `setCursor`, флаг выхода — `isExited`. Все обращения виджета наружу
|
|
6
|
+
* (`write`/`sendMouse`/`resize`) пишутся в публичные массивы для ассертов, а `onUpdate`/
|
|
7
|
+
* `onExit` дёргаются вручную через `emitUpdate`/`emitExit`.
|
|
8
|
+
*
|
|
9
|
+
* Прокрутка смоделирована состоянием, а не историей: `scrollbackLines` задаёт потолок
|
|
10
|
+
* для `scrollLines`, а `scrollOffset` можно проверять напрямую. Сетка — это всегда
|
|
11
|
+
* вьюпорт как он есть, смещение её не двигает (настоящий сдвиг строк проверяется на
|
|
12
|
+
* `EmbeddedTerminalSession`).
|
|
13
|
+
*/
|
|
14
|
+
export class FakeTerminalSurface {
|
|
15
|
+
grid = [];
|
|
16
|
+
cursor = null;
|
|
17
|
+
updateListeners = new Set();
|
|
18
|
+
exitListeners = new Set();
|
|
19
|
+
isExited = false;
|
|
20
|
+
/** Включила ли «программа в шелле» mouse-tracking — от этого зависит судьба колеса. */
|
|
21
|
+
mouseEventsActive = false;
|
|
22
|
+
/** Сколько строк истории лежит выше вьюпорта: потолок для `scrollLines`. */
|
|
23
|
+
scrollbackLines = 0;
|
|
24
|
+
scrollOffset = 0;
|
|
25
|
+
/** Стал ли фейк «убитым» — контроллер обязан звать dispose() при закрытии терминала. */
|
|
26
|
+
disposed = false;
|
|
27
|
+
// Записи обращений виджета — для ассертов.
|
|
28
|
+
writes = [];
|
|
29
|
+
mouseEvents = [];
|
|
30
|
+
resizes = [];
|
|
31
|
+
/** Заполнить сетку строками текста (каждый символ — обычная ячейка ширины 1). */
|
|
32
|
+
setGrid(lines) {
|
|
33
|
+
this.grid = lines.map((line) => Array.from(line, (char) => makeCell(char)));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Задать одну ячейку — здесь навешиваем цвета/стиль/ширину. При `width === 2`
|
|
37
|
+
* правый сосед (`x + 1`) помечается как continuation (readCell вернёт там false).
|
|
38
|
+
*/
|
|
39
|
+
setCell(x, y, char, options = {}) {
|
|
40
|
+
const row = this.ensureRow(y);
|
|
41
|
+
const width = options.width ?? 1;
|
|
42
|
+
row[x] = {
|
|
43
|
+
char,
|
|
44
|
+
fg: options.fg ?? DEFAULT_COLOR,
|
|
45
|
+
bg: options.bg ?? DEFAULT_COLOR,
|
|
46
|
+
style: options.style ?? 0,
|
|
47
|
+
width,
|
|
48
|
+
};
|
|
49
|
+
if (width === 2)
|
|
50
|
+
row[x + 1] = "continuation";
|
|
51
|
+
}
|
|
52
|
+
setCursor(cursor) {
|
|
53
|
+
this.cursor = cursor;
|
|
54
|
+
}
|
|
55
|
+
/** Дёрнуть подписчиков onUpdate (эмуляция «пришли новые данные из шелла»). */
|
|
56
|
+
emitUpdate() {
|
|
57
|
+
for (const cb of this.updateListeners)
|
|
58
|
+
cb();
|
|
59
|
+
}
|
|
60
|
+
/** Дёрнуть подписчиков onExit (эмуляция выхода шелла). */
|
|
61
|
+
emitExit(exitCode) {
|
|
62
|
+
this.isExited = true;
|
|
63
|
+
for (const cb of this.exitListeners)
|
|
64
|
+
cb(exitCode);
|
|
65
|
+
}
|
|
66
|
+
// ─── ITerminalSurface ───
|
|
67
|
+
readCell(x, y, out) {
|
|
68
|
+
const slot = this.grid[y]?.[x];
|
|
69
|
+
if (slot === undefined || slot === "continuation")
|
|
70
|
+
return false;
|
|
71
|
+
out.char = slot.char;
|
|
72
|
+
out.fg = slot.fg;
|
|
73
|
+
out.bg = slot.bg;
|
|
74
|
+
out.style = slot.style;
|
|
75
|
+
out.width = slot.width;
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
getCursor() {
|
|
79
|
+
return this.scrollOffset > 0 ? null : this.cursor; // в скролбэке курсор прячем
|
|
80
|
+
}
|
|
81
|
+
write(data) {
|
|
82
|
+
this.writes.push(data);
|
|
83
|
+
this.setScrollOffset(0); // как настоящая поверхность: ввод возвращает на дно
|
|
84
|
+
}
|
|
85
|
+
/** Прокрутка вьюпорта: `delta < 0` — вверх, в историю; клампится в [0, scrollbackLines]. */
|
|
86
|
+
scrollLines(delta) {
|
|
87
|
+
this.setScrollOffset(this.scrollOffset - delta);
|
|
88
|
+
}
|
|
89
|
+
sendMouse(event) {
|
|
90
|
+
this.mouseEvents.push(event);
|
|
91
|
+
}
|
|
92
|
+
resize(cols, rows) {
|
|
93
|
+
this.resizes.push({ cols, rows });
|
|
94
|
+
}
|
|
95
|
+
onUpdate(cb) {
|
|
96
|
+
this.updateListeners.add(cb);
|
|
97
|
+
return { dispose: () => this.updateListeners.delete(cb) };
|
|
98
|
+
}
|
|
99
|
+
onExit(cb) {
|
|
100
|
+
this.exitListeners.add(cb);
|
|
101
|
+
return { dispose: () => this.exitListeners.delete(cb) };
|
|
102
|
+
}
|
|
103
|
+
/** Помечает фейк убитым (в реале — kill PTY + dispose эмулятора). */
|
|
104
|
+
dispose() {
|
|
105
|
+
this.disposed = true;
|
|
106
|
+
}
|
|
107
|
+
setScrollOffset(value) {
|
|
108
|
+
const next = Math.max(0, Math.min(value, this.scrollbackLines));
|
|
109
|
+
if (next === this.scrollOffset)
|
|
110
|
+
return;
|
|
111
|
+
this.scrollOffset = next;
|
|
112
|
+
this.emitUpdate();
|
|
113
|
+
}
|
|
114
|
+
ensureRow(y) {
|
|
115
|
+
while (this.grid.length <= y)
|
|
116
|
+
this.grid.push([]);
|
|
117
|
+
return this.grid[y];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function makeCell(char) {
|
|
121
|
+
return { char, fg: DEFAULT_COLOR, bg: DEFAULT_COLOR, style: 0, width: 1 };
|
|
122
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Size } from "@tuidom/core/common/geometryPromitives";
|
|
2
|
+
import { TuiApplication } from "@tuidom/core/dom/tuiApplication";
|
|
3
|
+
import type { TUIElement } from "@tuidom/core/dom/tuiElement";
|
|
4
|
+
import { BodyElement } from "@tuidom/elements/body/bodyElement";
|
|
5
|
+
import { MockTerminalBackend } from "./mockTerminalBackend.js";
|
|
6
|
+
export declare class TestApp {
|
|
7
|
+
readonly backend: MockTerminalBackend;
|
|
8
|
+
readonly app: TuiApplication;
|
|
9
|
+
private constructor();
|
|
10
|
+
static create(root: BodyElement, size?: Size, styleVars?: Readonly<Record<string, number>> | null): TestApp;
|
|
11
|
+
static createWithContent(content: TUIElement, size?: Size, styleVars?: Readonly<Record<string, number>> | null): TestApp;
|
|
12
|
+
/** Корень как BodyElement: TuiApplication хранит рут широким типом (ядро
|
|
13
|
+
* tuidom не знает о виджетах), тестовый харнесс — конкретным. */
|
|
14
|
+
private readonly bodyRoot;
|
|
15
|
+
get root(): BodyElement;
|
|
16
|
+
get focusedElement(): TUIElement | null;
|
|
17
|
+
sendKey(name: string): void;
|
|
18
|
+
querySelector(selector: string): TUIElement | null;
|
|
19
|
+
querySelectorAll(selector: string): TUIElement[];
|
|
20
|
+
render(): void;
|
|
21
|
+
}
|
package/dist/TestApp.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Size } from "@tuidom/core/common/geometryPromitives";
|
|
2
|
+
import { TuiApplication } from "@tuidom/core/dom/tuiApplication";
|
|
3
|
+
import { BodyElement } from "@tuidom/elements/body/bodyElement";
|
|
4
|
+
import { DARK_PLUS_STYLE_VARS } from "./darkPlusStyleVars.js";
|
|
5
|
+
import { MockTerminalBackend } from "./mockTerminalBackend.js";
|
|
6
|
+
export class TestApp {
|
|
7
|
+
backend;
|
|
8
|
+
app;
|
|
9
|
+
constructor(backend, root, styleVars) {
|
|
10
|
+
this.backend = backend;
|
|
11
|
+
this.app = new TuiApplication(backend);
|
|
12
|
+
this.app.root = root;
|
|
13
|
+
this.bodyRoot = root;
|
|
14
|
+
// Корень получает палитру var-scope до первого кадра — компоненты в
|
|
15
|
+
// тестах резолвят токены в конкретные цвета. Дефолт — снапшот Dark+
|
|
16
|
+
// (паритет с приложением vexx); хост может подложить свою палитру.
|
|
17
|
+
if (styleVars !== null) {
|
|
18
|
+
root.setStyleVars(styleVars);
|
|
19
|
+
}
|
|
20
|
+
// Каждый кадр в тестах верифицирует инварианты дерева (симметрия parent,
|
|
21
|
+
// укоренённость) — молчаливые полуприкреплённые состояния падают сразу.
|
|
22
|
+
this.app.validateTreeAfterRender = true;
|
|
23
|
+
this.app.run();
|
|
24
|
+
}
|
|
25
|
+
static create(root, size = new Size(80, 24), styleVars = DARK_PLUS_STYLE_VARS) {
|
|
26
|
+
return new TestApp(new MockTerminalBackend(size), root, styleVars);
|
|
27
|
+
}
|
|
28
|
+
static createWithContent(content, size = new Size(80, 24), styleVars = DARK_PLUS_STYLE_VARS) {
|
|
29
|
+
const body = new BodyElement();
|
|
30
|
+
body.setContent(content);
|
|
31
|
+
return new TestApp(new MockTerminalBackend(size), body, styleVars);
|
|
32
|
+
}
|
|
33
|
+
/** Корень как BodyElement: TuiApplication хранит рут широким типом (ядро
|
|
34
|
+
* tuidom не знает о виджетах), тестовый харнесс — конкретным. */
|
|
35
|
+
bodyRoot;
|
|
36
|
+
get root() {
|
|
37
|
+
return this.bodyRoot;
|
|
38
|
+
}
|
|
39
|
+
get focusedElement() {
|
|
40
|
+
return this.app.focusManager?.activeElement ?? null;
|
|
41
|
+
}
|
|
42
|
+
sendKey(name) {
|
|
43
|
+
this.backend.sendKey(name);
|
|
44
|
+
}
|
|
45
|
+
querySelector(selector) {
|
|
46
|
+
return this.root.querySelector(selector);
|
|
47
|
+
}
|
|
48
|
+
querySelectorAll(selector) {
|
|
49
|
+
return this.root.querySelectorAll(selector);
|
|
50
|
+
}
|
|
51
|
+
render() {
|
|
52
|
+
// Force a synchronous render (app.run already did the initial one,
|
|
53
|
+
// and handleInput renders after each key, but this is useful
|
|
54
|
+
// if the test mutates state without going through input).
|
|
55
|
+
// @ts-expect-error Just for testing purposes, we want to bypass the normal async render scheduling
|
|
56
|
+
this.app.renderFrame();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const DARK_PLUS_STYLE_VARS: Readonly<Record<string, number>>;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Снапшот палитры Dark+ (vexx), выгруженный данными: тест-харнесс tuidom
|
|
2
|
+
// резолвит токены в те же цвета, что видит приложение vexx, не завися от
|
|
3
|
+
// его кода темы. Регенерация (из репозитория vexx):
|
|
4
|
+
// npx tsx <scratchpad>/gen-dark-plus-vars.ts > tuidom/testing/darkPlusStyleVars.ts
|
|
5
|
+
// где скрипт печатает computeThemeVars(WorkbenchTheme.fromThemeFile(darkPlusTheme)).
|
|
6
|
+
export const DARK_PLUS_STYLE_VARS = {
|
|
7
|
+
"actionBar.toggledBackground": 0x383a49,
|
|
8
|
+
"activityBarBadge.background": 0x007acc,
|
|
9
|
+
"agentsChatInput.border": 0x303031,
|
|
10
|
+
"agentsChatInput.focusBorder": 0x007acc,
|
|
11
|
+
"agentsNewSessionButton.border": 0x303031,
|
|
12
|
+
"agentsPanel.border": 0x303031,
|
|
13
|
+
"button.background": 0x0078d7,
|
|
14
|
+
"button.foreground": 0xffffff,
|
|
15
|
+
"button.hoverBackground": 0x1a86e0,
|
|
16
|
+
"button.secondaryBackground": 0x3c3c3c,
|
|
17
|
+
"button.secondaryForeground": 0xcccccc,
|
|
18
|
+
"button.secondaryHoverBackground": 0x45494e,
|
|
19
|
+
"checkbox.border": 0x6b6b6b,
|
|
20
|
+
descriptionForeground: 0xababab,
|
|
21
|
+
"diffEditor.diagonalFill": 0x414141,
|
|
22
|
+
"diffEditor.insertedLineBackground": 0x373d29,
|
|
23
|
+
"diffEditor.insertedTextBackground": 0x374121,
|
|
24
|
+
"diffEditor.removedLineBackground": 0x4b1818,
|
|
25
|
+
"diffEditor.removedTextBackground": 0x4b1818,
|
|
26
|
+
"diffEditor.unchangedRegionForeground": 0x8c8c8c,
|
|
27
|
+
"dropdown.background": 0x3c3c3c,
|
|
28
|
+
"dropdown.border": 0x3c3c3c,
|
|
29
|
+
"dropdown.foreground": 0xf0f0f0,
|
|
30
|
+
"dropdown.listBackground": 0x1f1f1f,
|
|
31
|
+
"editor.background": 0x1e1e1e,
|
|
32
|
+
"editor.foreground": 0xd4d4d4,
|
|
33
|
+
"editor.inactiveSelectionBackground": 0x3a3d41,
|
|
34
|
+
"editor.selectionHighlightBackground": 0xadd6ff,
|
|
35
|
+
"editor.wordHighlightBackground": 0x474747,
|
|
36
|
+
"editorCursor.foreground": 0xaeafad,
|
|
37
|
+
"editorError.foreground": 0xf14c4c,
|
|
38
|
+
"editorGroupHeader.tabsBackground": 0x252526,
|
|
39
|
+
"editorGutter.addedBackground": 0x487e02,
|
|
40
|
+
"editorGutter.deletedBackground": 0xf14c4c,
|
|
41
|
+
"editorGutter.foldingControlForeground": 0xc5c5c5,
|
|
42
|
+
"editorGutter.modifiedBackground": 0x1b81a8,
|
|
43
|
+
"editorHint.foreground": 0xeeeeee,
|
|
44
|
+
"editorIndentGuide.activeBackground1": 0x707070,
|
|
45
|
+
"editorIndentGuide.background1": 0x404040,
|
|
46
|
+
"editorInfo.foreground": 0x3794ff,
|
|
47
|
+
"editorLineNumber.activeForeground": 0xc6c6c6,
|
|
48
|
+
"editorLineNumber.foreground": 0x858585,
|
|
49
|
+
"editorWarning.foreground": 0xcca700,
|
|
50
|
+
"editorWidget.background": 0x252526,
|
|
51
|
+
"editorWidget.border": 0x454545,
|
|
52
|
+
"editorWidget.foreground": 0xcccccc,
|
|
53
|
+
focusBorder: 0x007fd4,
|
|
54
|
+
foreground: 0xcccccc,
|
|
55
|
+
"gitDecoration.addedResourceForeground": 0x81b88b,
|
|
56
|
+
"gitDecoration.conflictingResourceForeground": 0xe4676b,
|
|
57
|
+
"gitDecoration.deletedResourceForeground": 0xc74e39,
|
|
58
|
+
"gitDecoration.ignoredResourceForeground": 0x8c8c8c,
|
|
59
|
+
"gitDecoration.modifiedResourceForeground": 0xe2c08d,
|
|
60
|
+
"gitDecoration.renamedResourceForeground": 0x73c991,
|
|
61
|
+
"gitDecoration.submoduleResourceForeground": 0x8db9e2,
|
|
62
|
+
"gitDecoration.untrackedResourceForeground": 0x73c991,
|
|
63
|
+
"input.background": 0x3c3c3c,
|
|
64
|
+
"input.border": 0x3c3c3c,
|
|
65
|
+
"input.foreground": 0xcccccc,
|
|
66
|
+
"input.placeholderForeground": 0xa6a6a6,
|
|
67
|
+
"input.selectionBackground": 0x264f78,
|
|
68
|
+
"list.activeSelectionBackground": 0x04395e,
|
|
69
|
+
"list.activeSelectionForeground": 0xffffff,
|
|
70
|
+
"list.activeSelectionIconForeground": 0xffffff,
|
|
71
|
+
"list.deemphasizedForeground": 0x808080,
|
|
72
|
+
"list.dropBackground": 0x383b3d,
|
|
73
|
+
"list.highlightForeground": 0x64c8ff,
|
|
74
|
+
"list.hoverBackground": 0x2a2d2e,
|
|
75
|
+
"list.inactiveSelectionBackground": 0x37373d,
|
|
76
|
+
"list.inactiveSelectionForeground": 0xcccccc,
|
|
77
|
+
"menu.background": 0x252526,
|
|
78
|
+
"menu.border": 0x454545,
|
|
79
|
+
"menu.foreground": 0xcccccc,
|
|
80
|
+
"menu.selectionBackground": 0x0078d4,
|
|
81
|
+
"menu.selectionForeground": 0xffffff,
|
|
82
|
+
"menu.separatorBackground": 0x454545,
|
|
83
|
+
"menubar.selectionBackground": 0x005ab4,
|
|
84
|
+
"menubar.selectionForeground": 0xffffff,
|
|
85
|
+
"panel.background": 0x181818,
|
|
86
|
+
"panel.border": 0x2b2b2b,
|
|
87
|
+
"panelTitle.activeBorder": 0xe7e7e7,
|
|
88
|
+
"panelTitle.activeForeground": 0xe7e7e7,
|
|
89
|
+
"panelTitle.inactiveForeground": 0x8e8e8e,
|
|
90
|
+
"ports.iconRunningProcessForeground": 0x369432,
|
|
91
|
+
"quickInput.background": 0x252526,
|
|
92
|
+
"quickInput.foreground": 0xcccccc,
|
|
93
|
+
"sash.hoverBorder": 0x007fd4,
|
|
94
|
+
"scmGraph.foreground1": 0xffb000,
|
|
95
|
+
"scmGraph.foreground2": 0xdc267f,
|
|
96
|
+
"scmGraph.foreground3": 0x994f00,
|
|
97
|
+
"scmGraph.foreground4": 0x40b0a6,
|
|
98
|
+
"scmGraph.foreground5": 0xb66dff,
|
|
99
|
+
"scmGraph.historyItemBaseRefColor": 0xea5c00,
|
|
100
|
+
"scmGraph.historyItemRefColor": 0x3794ff,
|
|
101
|
+
"scmGraph.historyItemRemoteRefColor": 0xb180d7,
|
|
102
|
+
"scrollbar.background": 0x3a3d3e,
|
|
103
|
+
"scrollbarSlider.background": 0x797979,
|
|
104
|
+
"sideBar.background": 0x252526,
|
|
105
|
+
"sideBar.foreground": 0xcccccc,
|
|
106
|
+
"sideBarSectionHeader.background": 0x000000,
|
|
107
|
+
"sideBarSectionHeader.border": 0xcccccc,
|
|
108
|
+
"sideBarTitle.foreground": 0xbbbbbb,
|
|
109
|
+
"statusBar.background": 0x007acc,
|
|
110
|
+
"statusBar.foreground": 0xffffff,
|
|
111
|
+
"statusBarItem.hoverBackground": 0x1e92d2,
|
|
112
|
+
"statusBarItem.hoverForeground": 0xffffff,
|
|
113
|
+
"statusBarItem.remoteBackground": 0x16825d,
|
|
114
|
+
"statusBarItem.remoteForeground": 0xffffff,
|
|
115
|
+
"tab.activeBackground": 0x1e1e1e,
|
|
116
|
+
"tab.activeForeground": 0xffffff,
|
|
117
|
+
"tab.inactiveBackground": 0x2d2d2d,
|
|
118
|
+
"tab.inactiveForeground": 0xffffff,
|
|
119
|
+
"tab.lastPinnedBorder": 0xcccccc,
|
|
120
|
+
"tab.selectedBackground": 0x222222,
|
|
121
|
+
"tab.selectedForeground": 0xffffff,
|
|
122
|
+
"terminal.background": 0x181818,
|
|
123
|
+
"terminal.foreground": 0xcccccc,
|
|
124
|
+
"terminal.inactiveSelectionBackground": 0x3a3d41,
|
|
125
|
+
"textLink.foreground": 0x3794ff,
|
|
126
|
+
"toolbar.hoverBackground": 0x5a5d5e,
|
|
127
|
+
"widget.border": 0x303031,
|
|
128
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { MockTerminalBackend } from "./mockTerminalBackend.js";
|
|
2
|
+
/**
|
|
3
|
+
* Tagged template literal that strips leading/trailing blank lines
|
|
4
|
+
* and removes common indentation (dedent). Produces a clean multi-line
|
|
5
|
+
* string for visual screen assertions.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* screen`
|
|
9
|
+
* +----+
|
|
10
|
+
* | |
|
|
11
|
+
* +----+
|
|
12
|
+
* `
|
|
13
|
+
*/
|
|
14
|
+
export declare function screen(strings: TemplateStringsArray, ...values: unknown[]): string;
|
|
15
|
+
/**
|
|
16
|
+
* Assert that the backend's current screen matches the expected output.
|
|
17
|
+
*
|
|
18
|
+
* Usage:
|
|
19
|
+
* expectScreen(backend, screen`
|
|
20
|
+
* +----+
|
|
21
|
+
* | |
|
|
22
|
+
* +----+
|
|
23
|
+
* `);
|
|
24
|
+
*/
|
|
25
|
+
export declare function expectScreen(backend: MockTerminalBackend, expected: string): void;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { expect } from "vitest";
|
|
2
|
+
/**
|
|
3
|
+
* Tagged template literal that strips leading/trailing blank lines
|
|
4
|
+
* and removes common indentation (dedent). Produces a clean multi-line
|
|
5
|
+
* string for visual screen assertions.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* screen`
|
|
9
|
+
* +----+
|
|
10
|
+
* | |
|
|
11
|
+
* +----+
|
|
12
|
+
* `
|
|
13
|
+
*/
|
|
14
|
+
export function screen(strings, ...values) {
|
|
15
|
+
// Reconstruct the full string from template parts
|
|
16
|
+
let raw = strings[0];
|
|
17
|
+
for (let i = 0; i < values.length; i++) {
|
|
18
|
+
raw += String(values[i]) + strings[i + 1];
|
|
19
|
+
}
|
|
20
|
+
// Split into lines, remove first and last blank lines
|
|
21
|
+
let lines = raw.split("\n");
|
|
22
|
+
if (lines.length > 0 && lines[0].trim() === "") {
|
|
23
|
+
lines.shift();
|
|
24
|
+
}
|
|
25
|
+
if (lines.length > 0 && lines[lines.length - 1].trim() === "") {
|
|
26
|
+
lines.pop();
|
|
27
|
+
}
|
|
28
|
+
// Find minimum indentation (ignore empty lines)
|
|
29
|
+
const nonEmptyLines = lines.filter((l) => l.trim().length > 0);
|
|
30
|
+
const minIndent = nonEmptyLines.length > 0
|
|
31
|
+
? /* v8 ignore next -- /^(\s*)/ always matches, so exec never returns null; the ?? 0 fallback is unreachable */
|
|
32
|
+
Math.min(...nonEmptyLines.map((l) => /^(\s*)/.exec(l)?.[1].length ?? 0))
|
|
33
|
+
: 0;
|
|
34
|
+
// Strip common indent and trailing whitespace per line
|
|
35
|
+
lines = lines.map((l) => l.slice(minIndent).trimEnd());
|
|
36
|
+
return lines.join("\n");
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Normalize backend screen output: trim trailing spaces per line,
|
|
40
|
+
* then trim trailing empty lines. Returns a compact string that
|
|
41
|
+
* can be compared to `screen` tagged template output.
|
|
42
|
+
*/
|
|
43
|
+
function normalizeScreen(raw) {
|
|
44
|
+
const lines = raw.split("\n").map((l) => l.trimEnd());
|
|
45
|
+
// Remove trailing empty lines
|
|
46
|
+
while (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
47
|
+
lines.pop();
|
|
48
|
+
}
|
|
49
|
+
return lines.join("\n");
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Assert that the backend's current screen matches the expected output.
|
|
53
|
+
*
|
|
54
|
+
* Usage:
|
|
55
|
+
* expectScreen(backend, screen`
|
|
56
|
+
* +----+
|
|
57
|
+
* | |
|
|
58
|
+
* +----+
|
|
59
|
+
* `);
|
|
60
|
+
*/
|
|
61
|
+
export function expectScreen(backend, expected) {
|
|
62
|
+
const actual = normalizeScreen(backend.screenToString());
|
|
63
|
+
expect(actual).toBe(expected);
|
|
64
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { ITerminalBackend } from "@tuidom/core/backend/iTerminalBackend";
|
|
2
|
+
import { Point, Size } from "@tuidom/core/common/geometryPromitives";
|
|
3
|
+
import type { KeyPressEvent } from "@tuidom/core/input/keyEvent";
|
|
4
|
+
import type { MouseToken } from "@tuidom/core/input/rawTerminalToken";
|
|
5
|
+
import type { Grid } from "@tuidom/core/rendering/grid";
|
|
6
|
+
/**
|
|
7
|
+
* In-memory terminal backend for testing.
|
|
8
|
+
*
|
|
9
|
+
* Provides two ways to simulate input:
|
|
10
|
+
* - `sendKey('a')`, `sendKey('Ctrl+C')` — human-readable DSL
|
|
11
|
+
* - `sendRaw('\x1b[A')` — raw escape sequences for edge cases
|
|
12
|
+
*
|
|
13
|
+
* Stores screen state in a 2D grid for assertions via getTextAt() / screenToString().
|
|
14
|
+
*/
|
|
15
|
+
export declare class MockTerminalBackend implements ITerminalBackend {
|
|
16
|
+
private inputCallbacks;
|
|
17
|
+
private mouseCallbacks;
|
|
18
|
+
private pasteCallbacks;
|
|
19
|
+
private resizeCallbacks;
|
|
20
|
+
private oscResponseCallbacks;
|
|
21
|
+
private keyboardProbeCallbacks;
|
|
22
|
+
private cells;
|
|
23
|
+
private bgs;
|
|
24
|
+
private fgs;
|
|
25
|
+
size: Size;
|
|
26
|
+
private readonly inputParser;
|
|
27
|
+
constructor(size?: Size);
|
|
28
|
+
private createEmptyGrid;
|
|
29
|
+
private createBgGrid;
|
|
30
|
+
onInput(callback: (event: KeyPressEvent) => void): void;
|
|
31
|
+
onMouse(callback: (event: MouseToken) => void): void;
|
|
32
|
+
onPaste(callback: (text: string) => void): void;
|
|
33
|
+
onResize(callback: (size: Size) => void): void;
|
|
34
|
+
onOscResponse(callback: (code: number, data: string) => void): void;
|
|
35
|
+
probeKeyboardProtocol(onResult: (supported: boolean) => void): void;
|
|
36
|
+
cursorPosition: Point;
|
|
37
|
+
renderFrame(grid: Grid, cursorPosition: Point): void;
|
|
38
|
+
getSize(): Size;
|
|
39
|
+
setup(): void;
|
|
40
|
+
teardown(): void;
|
|
41
|
+
/** Set a character directly in the screen grid (test helper, not part of ITerminalBackend) */
|
|
42
|
+
setCellAt(position: Point, char: string): void;
|
|
43
|
+
/**
|
|
44
|
+
* Simulate a key press using human-readable name.
|
|
45
|
+
* Examples: sendKey('a'), sendKey('Enter'), sendKey('Ctrl+C')
|
|
46
|
+
*/
|
|
47
|
+
sendKey(name: string): void;
|
|
48
|
+
/**
|
|
49
|
+
* Force-deliver any partial sequence still buffered in the parser (test analogue of
|
|
50
|
+
* NodeTerminalBackend's flush timeout). Call after sending split raw chunks if the
|
|
51
|
+
* final piece left a dangling tail.
|
|
52
|
+
*/
|
|
53
|
+
flushInput(): void;
|
|
54
|
+
/**
|
|
55
|
+
* Simulate a bracketed paste: wrap `text` in the ESC[200~ … ESC[201~ markers the
|
|
56
|
+
* terminal emits and feed it through the parser, so the whole block is delivered to
|
|
57
|
+
* paste subscribers as one string.
|
|
58
|
+
*/
|
|
59
|
+
sendPaste(text: string): void;
|
|
60
|
+
private emitStreams;
|
|
61
|
+
/**
|
|
62
|
+
* Simulate a mouse event using a MouseToken object.
|
|
63
|
+
*/
|
|
64
|
+
simulateMouse(token: MouseToken): void;
|
|
65
|
+
/**
|
|
66
|
+
* Simulate an OSC response from the terminal (test helper).
|
|
67
|
+
*/
|
|
68
|
+
simulateOscResponse(code: number, data: string): void;
|
|
69
|
+
/**
|
|
70
|
+
* Resolve a pending keyboard-protocol probe (test helper) — simulates the terminal
|
|
71
|
+
* answering (or not) the Kitty keyboard-protocol query.
|
|
72
|
+
*/
|
|
73
|
+
resolveKeyboardProtocol(supported: boolean): void;
|
|
74
|
+
/**
|
|
75
|
+
* Simulate raw terminal input (escape sequences etc.)
|
|
76
|
+
* Example: sendRaw('\x1b[A') for arrow up
|
|
77
|
+
*/
|
|
78
|
+
sendRaw(data: string): void;
|
|
79
|
+
/**
|
|
80
|
+
* Read a horizontal run of characters from the screen grid.
|
|
81
|
+
*/
|
|
82
|
+
getTextAt(position: Point, length: number): string;
|
|
83
|
+
/**
|
|
84
|
+
* Render the entire screen as plain text (rows joined by \n).
|
|
85
|
+
* Useful for snapshot testing.
|
|
86
|
+
*/
|
|
87
|
+
screenToString(): string;
|
|
88
|
+
/** Clear the screen grid back to empty */
|
|
89
|
+
clearScreen(): void;
|
|
90
|
+
/**
|
|
91
|
+
* Simulate a terminal resize.
|
|
92
|
+
* Updates dimensions, recreates the grid, and notifies all resize callbacks.
|
|
93
|
+
*/
|
|
94
|
+
getBgAt(position: Point): number;
|
|
95
|
+
getFgAt(position: Point): number;
|
|
96
|
+
resize(size: Size): void;
|
|
97
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { DEFAULT_COLOR } from "@tuidom/core/common/colorUtils";
|
|
2
|
+
import { Point, Size } from "@tuidom/core/common/geometryPromitives";
|
|
3
|
+
import { KeyInputParser } from "@tuidom/core/input/keyInputParser";
|
|
4
|
+
import { serializeKey } from "@tuidom/core/input/serializeKey";
|
|
5
|
+
/**
|
|
6
|
+
* In-memory terminal backend for testing.
|
|
7
|
+
*
|
|
8
|
+
* Provides two ways to simulate input:
|
|
9
|
+
* - `sendKey('a')`, `sendKey('Ctrl+C')` — human-readable DSL
|
|
10
|
+
* - `sendRaw('\x1b[A')` — raw escape sequences for edge cases
|
|
11
|
+
*
|
|
12
|
+
* Stores screen state in a 2D grid for assertions via getTextAt() / screenToString().
|
|
13
|
+
*/
|
|
14
|
+
export class MockTerminalBackend {
|
|
15
|
+
inputCallbacks = [];
|
|
16
|
+
mouseCallbacks = [];
|
|
17
|
+
pasteCallbacks = [];
|
|
18
|
+
resizeCallbacks = [];
|
|
19
|
+
oscResponseCallbacks = [];
|
|
20
|
+
keyboardProbeCallbacks = [];
|
|
21
|
+
cells;
|
|
22
|
+
bgs;
|
|
23
|
+
fgs;
|
|
24
|
+
size;
|
|
25
|
+
inputParser = new KeyInputParser();
|
|
26
|
+
constructor(size = new Size(80, 24)) {
|
|
27
|
+
this.size = size;
|
|
28
|
+
this.cells = this.createEmptyGrid();
|
|
29
|
+
this.bgs = this.createBgGrid();
|
|
30
|
+
this.fgs = this.createBgGrid();
|
|
31
|
+
}
|
|
32
|
+
createEmptyGrid() {
|
|
33
|
+
const value = null;
|
|
34
|
+
return new Array(this.size.height)
|
|
35
|
+
.fill([])
|
|
36
|
+
.map(() => new Array(this.size.width).fill(value));
|
|
37
|
+
}
|
|
38
|
+
createBgGrid() {
|
|
39
|
+
return new Array(this.size.height)
|
|
40
|
+
.fill([])
|
|
41
|
+
.map(() => new Array(this.size.width).fill(DEFAULT_COLOR));
|
|
42
|
+
}
|
|
43
|
+
onInput(callback) {
|
|
44
|
+
this.inputCallbacks.push(callback);
|
|
45
|
+
}
|
|
46
|
+
onMouse(callback) {
|
|
47
|
+
this.mouseCallbacks.push(callback);
|
|
48
|
+
}
|
|
49
|
+
onPaste(callback) {
|
|
50
|
+
this.pasteCallbacks.push(callback);
|
|
51
|
+
}
|
|
52
|
+
onResize(callback) {
|
|
53
|
+
this.resizeCallbacks.push(callback);
|
|
54
|
+
}
|
|
55
|
+
onOscResponse(callback) {
|
|
56
|
+
this.oscResponseCallbacks.push(callback);
|
|
57
|
+
}
|
|
58
|
+
probeKeyboardProtocol(onResult) {
|
|
59
|
+
this.keyboardProbeCallbacks.push(onResult);
|
|
60
|
+
}
|
|
61
|
+
cursorPosition = new Point(0, 0);
|
|
62
|
+
renderFrame(grid, cursorPosition) {
|
|
63
|
+
for (let y = 0; y < grid.height && y < this.size.height; y++) {
|
|
64
|
+
for (let x = 0; x < grid.width && x < this.size.width; x++) {
|
|
65
|
+
const cell = grid.getCellAt(x, y);
|
|
66
|
+
this.cells[y][x] = cell.char;
|
|
67
|
+
this.bgs[y][x] = cell.bg;
|
|
68
|
+
this.fgs[y][x] = cell.fg;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
this.cursorPosition = cursorPosition;
|
|
72
|
+
}
|
|
73
|
+
getSize() {
|
|
74
|
+
return this.size;
|
|
75
|
+
}
|
|
76
|
+
setup() {
|
|
77
|
+
// No-op for mock
|
|
78
|
+
}
|
|
79
|
+
teardown() {
|
|
80
|
+
// No-op for mock
|
|
81
|
+
}
|
|
82
|
+
// ─── Test helpers ───
|
|
83
|
+
/** Set a character directly in the screen grid (test helper, not part of ITerminalBackend) */
|
|
84
|
+
setCellAt(position, char) {
|
|
85
|
+
if (position.y >= 0 && position.y < this.size.height && position.x >= 0 && position.x < this.size.width) {
|
|
86
|
+
this.cells[position.y][position.x] = char;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Simulate a key press using human-readable name.
|
|
91
|
+
* Examples: sendKey('a'), sendKey('Enter'), sendKey('Ctrl+C')
|
|
92
|
+
*/
|
|
93
|
+
sendKey(name) {
|
|
94
|
+
const raw = serializeKey(name);
|
|
95
|
+
this.emitStreams(this.inputParser.parseWithMouse(raw));
|
|
96
|
+
// serializeKey always produces a complete sequence, so deliver immediately —
|
|
97
|
+
// a lone ESC ("Escape") would otherwise sit buffered awaiting a continuation.
|
|
98
|
+
this.flushInput();
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Force-deliver any partial sequence still buffered in the parser (test analogue of
|
|
102
|
+
* NodeTerminalBackend's flush timeout). Call after sending split raw chunks if the
|
|
103
|
+
* final piece left a dangling tail.
|
|
104
|
+
*/
|
|
105
|
+
flushInput() {
|
|
106
|
+
if (this.inputParser.hasPending()) {
|
|
107
|
+
this.emitStreams(this.inputParser.flush());
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Simulate a bracketed paste: wrap `text` in the ESC[200~ … ESC[201~ markers the
|
|
112
|
+
* terminal emits and feed it through the parser, so the whole block is delivered to
|
|
113
|
+
* paste subscribers as one string.
|
|
114
|
+
*/
|
|
115
|
+
sendPaste(text) {
|
|
116
|
+
this.sendRaw(`\x1b[200~${text}\x1b[201~`);
|
|
117
|
+
}
|
|
118
|
+
emitStreams(streams) {
|
|
119
|
+
for (const event of streams.keys) {
|
|
120
|
+
for (const cb of this.inputCallbacks) {
|
|
121
|
+
cb(event);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const text of streams.paste) {
|
|
125
|
+
for (const cb of this.pasteCallbacks) {
|
|
126
|
+
cb(text);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Simulate a mouse event using a MouseToken object.
|
|
132
|
+
*/
|
|
133
|
+
simulateMouse(token) {
|
|
134
|
+
for (const cb of this.mouseCallbacks) {
|
|
135
|
+
cb(token);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Simulate an OSC response from the terminal (test helper).
|
|
140
|
+
*/
|
|
141
|
+
simulateOscResponse(code, data) {
|
|
142
|
+
for (const cb of this.oscResponseCallbacks) {
|
|
143
|
+
cb(code, data);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Resolve a pending keyboard-protocol probe (test helper) — simulates the terminal
|
|
148
|
+
* answering (or not) the Kitty keyboard-protocol query.
|
|
149
|
+
*/
|
|
150
|
+
resolveKeyboardProtocol(supported) {
|
|
151
|
+
const callbacks = this.keyboardProbeCallbacks;
|
|
152
|
+
this.keyboardProbeCallbacks = [];
|
|
153
|
+
for (const cb of callbacks) {
|
|
154
|
+
cb(supported);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Simulate raw terminal input (escape sequences etc.)
|
|
159
|
+
* Example: sendRaw('\x1b[A') for arrow up
|
|
160
|
+
*/
|
|
161
|
+
sendRaw(data) {
|
|
162
|
+
// No implicit flush: callers may deliberately send a partial sequence to
|
|
163
|
+
// simulate stdin packetization (use flushInput() to drain a leftover tail).
|
|
164
|
+
this.emitStreams(this.inputParser.parseWithMouse(data));
|
|
165
|
+
}
|
|
166
|
+
// ─── Screen assertions ───
|
|
167
|
+
/**
|
|
168
|
+
* Read a horizontal run of characters from the screen grid.
|
|
169
|
+
*/
|
|
170
|
+
getTextAt(position, length) {
|
|
171
|
+
let result = "";
|
|
172
|
+
for (let i = 0; i < length; i++) {
|
|
173
|
+
const cell = position.y >= 0 &&
|
|
174
|
+
position.y < this.size.height &&
|
|
175
|
+
position.x + i >= 0 &&
|
|
176
|
+
position.x + i < this.size.width
|
|
177
|
+
? this.cells[position.y][position.x + i]
|
|
178
|
+
: null;
|
|
179
|
+
result += cell ?? " ";
|
|
180
|
+
}
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Render the entire screen as plain text (rows joined by \n).
|
|
185
|
+
* Useful for snapshot testing.
|
|
186
|
+
*/
|
|
187
|
+
screenToString() {
|
|
188
|
+
const lines = [];
|
|
189
|
+
for (let y = 0; y < this.size.height; y++) {
|
|
190
|
+
let line = "";
|
|
191
|
+
for (let x = 0; x < this.size.width; x++) {
|
|
192
|
+
line += this.cells[y][x] ?? " ";
|
|
193
|
+
}
|
|
194
|
+
lines.push(line);
|
|
195
|
+
}
|
|
196
|
+
return lines.join("\n");
|
|
197
|
+
}
|
|
198
|
+
/** Clear the screen grid back to empty */
|
|
199
|
+
clearScreen() {
|
|
200
|
+
this.cells = this.createEmptyGrid();
|
|
201
|
+
this.bgs = this.createBgGrid();
|
|
202
|
+
this.fgs = this.createBgGrid();
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Simulate a terminal resize.
|
|
206
|
+
* Updates dimensions, recreates the grid, and notifies all resize callbacks.
|
|
207
|
+
*/
|
|
208
|
+
getBgAt(position) {
|
|
209
|
+
if (position.y >= 0 && position.y < this.size.height && position.x >= 0 && position.x < this.size.width) {
|
|
210
|
+
return this.bgs[position.y][position.x];
|
|
211
|
+
}
|
|
212
|
+
return DEFAULT_COLOR;
|
|
213
|
+
}
|
|
214
|
+
getFgAt(position) {
|
|
215
|
+
if (position.y >= 0 && position.y < this.size.height && position.x >= 0 && position.x < this.size.width) {
|
|
216
|
+
return this.fgs[position.y][position.x];
|
|
217
|
+
}
|
|
218
|
+
return DEFAULT_COLOR;
|
|
219
|
+
}
|
|
220
|
+
resize(size) {
|
|
221
|
+
this.size = size;
|
|
222
|
+
this.cells = this.createEmptyGrid();
|
|
223
|
+
this.bgs = this.createBgGrid();
|
|
224
|
+
this.fgs = this.createBgGrid();
|
|
225
|
+
for (const cb of this.resizeCallbacks) {
|
|
226
|
+
cb(size);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ITreeDataProvider } from "@tuidom/elements/tree/iTreeDataProvider";
|
|
2
|
+
/** Создаёт уникальную временную директорию под os.tmpdir(). */
|
|
3
|
+
export declare function createTempDir(prefix?: string): string;
|
|
4
|
+
/** Рекурсивно удаляет директорию (для afterAll/afterEach). */
|
|
5
|
+
export declare function cleanupDir(dirPath: string): void;
|
|
6
|
+
export interface GenerateFileTreeOptions {
|
|
7
|
+
/** Сколько файлов всего сгенерировать. */
|
|
8
|
+
files: number;
|
|
9
|
+
/** Сколько файлов кладём в каждую директорию (default 50). */
|
|
10
|
+
filesPerDir?: number;
|
|
11
|
+
/** Сколько разных директорий на каждом уровне вложенности (default 8). */
|
|
12
|
+
breadth?: number;
|
|
13
|
+
/** Максимальная глубина вложенности директорий (default 4). */
|
|
14
|
+
maxDepth?: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Генерирует вложенное дерево пустых файлов под `root`.
|
|
18
|
+
*
|
|
19
|
+
* Файлы раскидываются по директориям разной глубины, чтобы фикстура была
|
|
20
|
+
* похожа на реальный репозиторий (а не плоский список). Возвращает фактическое
|
|
21
|
+
* число записанных файлов.
|
|
22
|
+
*/
|
|
23
|
+
export declare function generateFileTree(root: string, options: GenerateFileTreeOptions): number;
|
|
24
|
+
export interface PerfTreeNode {
|
|
25
|
+
id: string;
|
|
26
|
+
label: string;
|
|
27
|
+
children?: PerfTreeNode[];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Строит вложенное in-memory дерево примерно из `targetCount` узлов с заданным
|
|
31
|
+
* ветвлением `fanout`. Без обращений к файловой системе — чтобы бенчмарки
|
|
32
|
+
* виджета мерили стоимость flatten/сканов, а не диск.
|
|
33
|
+
*/
|
|
34
|
+
export declare function buildInMemoryTree(targetCount: number, fanout?: number): PerfTreeNode[];
|
|
35
|
+
/** ITreeDataProvider поверх in-memory дерева PerfTreeNode. */
|
|
36
|
+
export declare function makeInMemoryTreeProvider(roots: PerfTreeNode[]): ITreeDataProvider<PerfTreeNode>;
|
|
37
|
+
/** Собирает все collapsible-узлы дерева (для массового раскрытия в бенче). */
|
|
38
|
+
export declare function collectCollapsibleNodes(roots: PerfTreeNode[]): PerfTreeNode[];
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
// ─── Файловые фикстуры ──────────────────────────────────────────────────────
|
|
5
|
+
/** Создаёт уникальную временную директорию под os.tmpdir(). */
|
|
6
|
+
export function createTempDir(prefix = "vexx-perf-") {
|
|
7
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
8
|
+
}
|
|
9
|
+
/** Рекурсивно удаляет директорию (для afterAll/afterEach). */
|
|
10
|
+
export function cleanupDir(dirPath) {
|
|
11
|
+
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Генерирует вложенное дерево пустых файлов под `root`.
|
|
15
|
+
*
|
|
16
|
+
* Файлы раскидываются по директориям разной глубины, чтобы фикстура была
|
|
17
|
+
* похожа на реальный репозиторий (а не плоский список). Возвращает фактическое
|
|
18
|
+
* число записанных файлов.
|
|
19
|
+
*/
|
|
20
|
+
export function generateFileTree(root, options) {
|
|
21
|
+
const { files, filesPerDir = 50, breadth = 8, maxDepth = 4 } = options;
|
|
22
|
+
let written = 0;
|
|
23
|
+
let dirIndex = 0;
|
|
24
|
+
while (written < files) {
|
|
25
|
+
// Псевдослучайная (детерминированная) вложенность для разнообразия.
|
|
26
|
+
const depth = (dirIndex % maxDepth) + 1;
|
|
27
|
+
const segments = [];
|
|
28
|
+
for (let level = 0; level < depth; level++) {
|
|
29
|
+
const bucket = Math.floor(dirIndex / breadth ** level) % breadth;
|
|
30
|
+
segments.push(`dir${level}_${bucket}`);
|
|
31
|
+
}
|
|
32
|
+
const dirAbs = path.join(root, ...segments);
|
|
33
|
+
fs.mkdirSync(dirAbs, { recursive: true });
|
|
34
|
+
for (let f = 0; f < filesPerDir && written < files; f++) {
|
|
35
|
+
fs.writeFileSync(path.join(dirAbs, `module${written}.ts`), "");
|
|
36
|
+
written++;
|
|
37
|
+
}
|
|
38
|
+
dirIndex++;
|
|
39
|
+
}
|
|
40
|
+
return written;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Строит вложенное in-memory дерево примерно из `targetCount` узлов с заданным
|
|
44
|
+
* ветвлением `fanout`. Без обращений к файловой системе — чтобы бенчмарки
|
|
45
|
+
* виджета мерили стоимость flatten/сканов, а не диск.
|
|
46
|
+
*/
|
|
47
|
+
export function buildInMemoryTree(targetCount, fanout = 6) {
|
|
48
|
+
let created = 0;
|
|
49
|
+
const makeLevel = (prefix, depth) => {
|
|
50
|
+
const nodes = [];
|
|
51
|
+
for (let i = 0; i < fanout && created < targetCount; i++) {
|
|
52
|
+
const id = `${prefix}/${i}`;
|
|
53
|
+
created++;
|
|
54
|
+
const node = { id, label: `node-${id}` };
|
|
55
|
+
// Ветвимся, пока не набрали нужное число узлов и не ушли слишком глубоко.
|
|
56
|
+
if (depth < 5 && created < targetCount) {
|
|
57
|
+
node.children = makeLevel(id, depth + 1);
|
|
58
|
+
}
|
|
59
|
+
nodes.push(node);
|
|
60
|
+
}
|
|
61
|
+
return nodes;
|
|
62
|
+
};
|
|
63
|
+
return makeLevel("n", 0);
|
|
64
|
+
}
|
|
65
|
+
/** ITreeDataProvider поверх in-memory дерева PerfTreeNode. */
|
|
66
|
+
export function makeInMemoryTreeProvider(roots) {
|
|
67
|
+
return {
|
|
68
|
+
getTreeItem(element) {
|
|
69
|
+
return {
|
|
70
|
+
label: element.label,
|
|
71
|
+
collapsible: (element.children?.length ?? 0) > 0,
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
getChildren(element) {
|
|
75
|
+
if (!element)
|
|
76
|
+
return roots;
|
|
77
|
+
return element.children ?? [];
|
|
78
|
+
},
|
|
79
|
+
getKey(element) {
|
|
80
|
+
return element.id;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** Собирает все collapsible-узлы дерева (для массового раскрытия в бенче). */
|
|
85
|
+
export function collectCollapsibleNodes(roots) {
|
|
86
|
+
const out = [];
|
|
87
|
+
const walk = (nodes) => {
|
|
88
|
+
for (const node of nodes) {
|
|
89
|
+
if (node.children && node.children.length > 0) {
|
|
90
|
+
out.push(node);
|
|
91
|
+
walk(node.children);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
walk(roots);
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { BoxConstraints } from "@tuidom/core/common/geometryPromitives";
|
|
2
|
+
import type { TUIElement } from "@tuidom/core/dom/tuiElement";
|
|
3
|
+
import { MockTerminalBackend } from "./mockTerminalBackend.js";
|
|
4
|
+
export interface IRenderElementOptions {
|
|
5
|
+
/** Constraints для layout; по умолчанию `BoxConstraints.tight(size)` бэкенда. */
|
|
6
|
+
readonly constraints?: BoxConstraints;
|
|
7
|
+
/** Прогнать `performStyleResolution` перед render (нужно элементам с per-char стилями). */
|
|
8
|
+
readonly resolveStyles?: boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Положить палитру dark+ в var-scope элемента перед резолвом — для
|
|
11
|
+
* хостовых компонентов, чьи токены (sideBar.*, statusBar.*) не имеют
|
|
12
|
+
* дефолтов tuidom. Включает resolveStyles.
|
|
13
|
+
*/
|
|
14
|
+
readonly themeVars?: boolean;
|
|
15
|
+
/** Явная палитра var-scope вместо снапшота dark+. Включает resolveStyles. */
|
|
16
|
+
readonly styleVars?: Readonly<Record<string, number>>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Single-shot рендер standalone-элемента в {@link MockTerminalBackend}
|
|
20
|
+
* заданного размера: layout → (опц.) style resolution → render → flush.
|
|
21
|
+
* Результат скармливается прямо в `expectScreen`. Для мультифреймовых
|
|
22
|
+
* сценариев или доступа к `TerminalScreen` — ручной сетап.
|
|
23
|
+
*/
|
|
24
|
+
export declare function renderElement(element: TUIElement, width: number, height: number, options?: IRenderElementOptions): MockTerminalBackend;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { BoxConstraints, Offset, Point, Rect, Size } from "@tuidom/core/common/geometryPromitives";
|
|
2
|
+
import { ROOT_STYLE_CONTEXT } from "@tuidom/core/dom/styles/tuiStyle";
|
|
3
|
+
import { RenderContext } from "@tuidom/core/dom/tuiElement";
|
|
4
|
+
import { TerminalScreen } from "@tuidom/core/rendering/terminalScreen";
|
|
5
|
+
import { DARK_PLUS_STYLE_VARS } from "./darkPlusStyleVars.js";
|
|
6
|
+
import { MockTerminalBackend } from "./mockTerminalBackend.js";
|
|
7
|
+
/**
|
|
8
|
+
* Single-shot рендер standalone-элемента в {@link MockTerminalBackend}
|
|
9
|
+
* заданного размера: layout → (опц.) style resolution → render → flush.
|
|
10
|
+
* Результат скармливается прямо в `expectScreen`. Для мультифреймовых
|
|
11
|
+
* сценариев или доступа к `TerminalScreen` — ручной сетап.
|
|
12
|
+
*/
|
|
13
|
+
export function renderElement(element, width, height, options = {}) {
|
|
14
|
+
const size = new Size(width, height);
|
|
15
|
+
const backend = new MockTerminalBackend(size);
|
|
16
|
+
const termScreen = new TerminalScreen(size);
|
|
17
|
+
// globalPosition производный: у элемента без родителя он равен localPosition.
|
|
18
|
+
element.localPosition = new Offset(0, 0);
|
|
19
|
+
element.layout(options.constraints ?? BoxConstraints.tight(size));
|
|
20
|
+
const styleVars = options.styleVars ?? (options.themeVars === true ? DARK_PLUS_STYLE_VARS : undefined);
|
|
21
|
+
if (styleVars !== undefined) {
|
|
22
|
+
element.setStyleVars(styleVars);
|
|
23
|
+
}
|
|
24
|
+
if (options.resolveStyles === true || styleVars !== undefined) {
|
|
25
|
+
element.performStyleResolution(ROOT_STYLE_CONTEXT);
|
|
26
|
+
}
|
|
27
|
+
// Клип по краям экрана — как в TuiApplication: переполняющий layout не
|
|
28
|
+
// должен писать за пределы grid (там нет bounds-чека, это crash).
|
|
29
|
+
const screenClip = new Rect(new Point(0, 0), size);
|
|
30
|
+
element.render(new RenderContext(termScreen, new Offset(0, 0), screenClip));
|
|
31
|
+
termScreen.flush(backend);
|
|
32
|
+
return backend;
|
|
33
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { TuiApplication } from "@tuidom/core/dom/tuiApplication";
|
|
2
|
+
import type { TUIElement } from "@tuidom/core/dom/tuiElement";
|
|
3
|
+
import type { BodyElement } from "@tuidom/elements/body/bodyElement";
|
|
4
|
+
export interface StoryContext {
|
|
5
|
+
readonly app: TuiApplication;
|
|
6
|
+
readonly body: BodyElement;
|
|
7
|
+
readonly args: string[];
|
|
8
|
+
afterRun(cb: () => void | Promise<void>): void;
|
|
9
|
+
}
|
|
10
|
+
export type StoryFunction = (ctx: StoryContext) => TUIElement | undefined | Promise<TUIElement | undefined>;
|
|
11
|
+
export interface StoryMeta {
|
|
12
|
+
title?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface StoryModule {
|
|
15
|
+
meta?: StoryMeta;
|
|
16
|
+
[key: string]: StoryFunction | StoryMeta | undefined;
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tuidom/testing",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Experimental terminal UI DOM — test harness: TestApp, renderElement, expectScreen, mock terminal backend. API is unstable.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/tuidom/tuidom.git",
|
|
10
|
+
"directory": "packages/testing"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=24.0.0"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"tui",
|
|
17
|
+
"terminal",
|
|
18
|
+
"ui",
|
|
19
|
+
"dom",
|
|
20
|
+
"testing"
|
|
21
|
+
],
|
|
22
|
+
"files": [
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
25
|
+
"exports": {
|
|
26
|
+
"./package.json": "./package.json",
|
|
27
|
+
"./*.js": "./dist/*.js",
|
|
28
|
+
"./*": "./dist/*.js"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@tuidom/core": "0.1.0",
|
|
32
|
+
"@tuidom/elements": "0.1.0"
|
|
33
|
+
}
|
|
34
|
+
}
|