@alchemy.run/sigil 0.0.0-alpha.4 → 0.0.0-alpha.6
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/README.md +21 -9
- package/THIRD_PARTY_NOTICES.md +39 -1
- package/dist/Text-BobFKi74.d.ts +452 -0
- package/dist/ansi.d.ts +122 -131
- package/dist/ansi.js +86 -6
- package/dist/capabilities.d.ts +5 -0
- package/dist/capabilities.js +3 -0
- package/dist/cell-_ZVhbfl0.js +44 -0
- package/dist/color-CkbalRqK.js +2 -0
- package/dist/color-policy-BMzMwV7Q.d.ts +22 -0
- package/dist/color-policy-SVj1pYTA.js +560 -0
- package/dist/color-profile-DHhQHY55.js +36 -0
- package/dist/color-profile-u0Nhe9Nv.d.ts +97 -0
- package/dist/color.d.ts +21 -0
- package/dist/color.js +3 -0
- package/dist/cursor-position-D2LAkRG0.d.ts +7 -0
- package/dist/detect-Bh4yGP6w.d.ts +186 -0
- package/dist/detect-BuTXtY6e.js +373 -0
- package/dist/env-YVw64yZS.js +9 -0
- package/dist/escapes-CB_6CWOE.d.ts +72 -0
- package/dist/geometry-BxXOzJgo.d.ts +11 -0
- package/dist/index-DZ88EXJv.d.ts +21 -0
- package/dist/index.d.ts +143 -498
- package/dist/index.js +1026 -2244
- package/dist/osc-CCH7xDoS.js +71 -0
- package/dist/osc-Cn0fw77g.d.ts +23 -0
- package/dist/paint-C19minOS.d.ts +81 -0
- package/dist/query-vaIeGOkH.d.ts +152 -0
- package/dist/router.d.ts +392 -0
- package/dist/router.js +709 -0
- package/dist/sample-Cqw1bjUL.js +445 -0
- package/dist/screen-BOSLQ8fF.d.ts +49 -0
- package/dist/screen-CiPytswf.js +342 -0
- package/dist/screen.d.ts +5 -0
- package/dist/screen.js +5 -0
- package/dist/semantic-text-style-DIMzC7xt.js +91 -0
- package/dist/serialize-BTkAZgw1.js +79 -0
- package/dist/session-aZr9O8h3.js +665 -0
- package/dist/sgr-BhwaWAJB.js +246 -0
- package/dist/store-CgrG9K4y.d.ts +72 -0
- package/dist/string-width-CijQwpIk.js +69 -0
- package/dist/strip-BvU4toXG.js +6 -0
- package/dist/terminal.d.ts +118 -0
- package/dist/terminal.js +2 -0
- package/dist/tokenize-AjqbvtiT.js +1242 -0
- package/dist/tokenize-Dx1y_l5H.d.ts +57 -0
- package/dist/truncate-D31fhU6i.js +562 -0
- package/dist/use-focus-BzqAJi0n.js +1337 -0
- package/package.json +41 -9
- package/src/ansi/chalk.ts +5 -3
- package/src/ansi/escapes.ts +14 -0
- package/src/ansi/graphemes.ts +8 -0
- package/src/ansi/hyperlink.ts +44 -0
- package/src/ansi/index.ts +3 -1
- package/src/ansi/osc.ts +77 -0
- package/src/ansi/tokenize.ts +3 -4
- package/src/capabilities/color-policy.ts +34 -0
- package/src/capabilities/detect.ts +594 -0
- package/src/capabilities/index.ts +37 -0
- package/src/capabilities/query.ts +657 -0
- package/src/capabilities/store.ts +379 -0
- package/src/color/index.ts +3 -0
- package/src/color/paint.ts +169 -0
- package/src/color/palette.ts +48 -0
- package/src/color/sample.ts +323 -0
- package/src/color.ts +1 -0
- package/src/components/AnsiText.tsx +42 -0
- package/src/components/App.tsx +98 -10
- package/src/components/BackgroundContext.ts +2 -3
- package/src/components/Box.tsx +0 -8
- package/src/components/CursorContext.ts +1 -1
- package/src/components/Hyperlink.tsx +56 -0
- package/src/components/TerminalOscContext.ts +25 -0
- package/src/components/Text.tsx +22 -45
- package/src/components/Transform.tsx +1 -1
- package/src/dom.ts +11 -2
- package/src/global.d.ts +3 -0
- package/src/hooks/use-capabilities.ts +73 -0
- package/src/hooks/use-cursor.ts +2 -2
- package/src/hooks/use-terminal-osc.ts +59 -0
- package/src/index.ts +45 -1
- package/src/ink.tsx +213 -153
- package/src/{render-node-to-output.ts → paint-tree.ts} +75 -48
- package/src/reconciler.ts +24 -3
- package/src/render-background.ts +36 -15
- package/src/render-border.ts +94 -61
- package/src/render-frame.ts +83 -0
- package/src/render-to-string.ts +21 -6
- package/src/render.ts +15 -6
- package/src/router/components.tsx +343 -0
- package/src/router/context.ts +41 -0
- package/src/router/history.ts +194 -0
- package/src/router/hooks.tsx +391 -0
- package/src/router/index.ts +34 -0
- package/src/router/matcher.ts +571 -0
- package/src/screen/ansi.ts +184 -0
- package/src/screen/canvas.ts +160 -0
- package/src/screen/cell.ts +138 -0
- package/src/screen/color-profile.ts +47 -0
- package/src/screen/geometry.ts +9 -0
- package/src/screen/index.ts +6 -0
- package/src/screen/screen.ts +305 -0
- package/src/screen/serialize.ts +129 -0
- package/src/screen.ts +1 -0
- package/src/semantic-text-style.ts +118 -0
- package/src/squash-text-nodes.ts +2 -5
- package/src/structured-text.ts +325 -0
- package/src/styles.ts +19 -14
- package/src/terminal/index.ts +2 -0
- package/src/terminal/inline-presenter.ts +120 -0
- package/src/terminal/input.ts +86 -0
- package/src/terminal/render-scheduler.ts +37 -0
- package/src/terminal/screen-presenter.ts +188 -0
- package/src/terminal/session.ts +407 -0
- package/src/terminal.ts +1 -0
- package/src/testing/browser.ts +588 -0
- package/src/testing/emulators.ts +205 -0
- package/src/testing/explorer-app/index.html +12 -0
- package/src/testing/explorer-app/main.ts +381 -0
- package/src/testing/explorer-app/style.css +194 -0
- package/src/testing/explorer-app/tsconfig.json +15 -0
- package/src/testing/explorer-app/vite-env.d.ts +1 -0
- package/src/testing/index.ts +26 -0
- package/src/testing/keys.ts +56 -0
- package/src/testing/live.ts +85 -0
- package/src/testing/matchers.ts +70 -0
- package/src/testing/public.ts +94 -0
- package/src/testing/terminal.ts +349 -0
- package/src/testing/vitest.ts +157 -0
- package/src/transform-adapter.ts +14 -0
- package/src/wrap-text.ts +4 -0
- package/dist/sgr-CMfEpjSk.d.ts +0 -91
- package/dist/truncate-Cr6xVFMa.js +0 -2330
- package/src/ansi/supports-color.ts +0 -207
- package/src/colorize.ts +0 -60
- package/src/log-update.ts +0 -370
- package/src/output.ts +0 -308
- package/src/renderer.ts +0 -73
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
body {
|
|
2
|
+
margin: 0;
|
|
3
|
+
background: #101014;
|
|
4
|
+
color: #c0caf5;
|
|
5
|
+
font-family: monospace;
|
|
6
|
+
font-size: 13px;
|
|
7
|
+
}
|
|
8
|
+
a {
|
|
9
|
+
color: #7aa2f7;
|
|
10
|
+
text-decoration: none;
|
|
11
|
+
}
|
|
12
|
+
a:hover {
|
|
13
|
+
text-decoration: underline;
|
|
14
|
+
}
|
|
15
|
+
h1 {
|
|
16
|
+
font-size: 16px;
|
|
17
|
+
margin: 0 0 4px;
|
|
18
|
+
}
|
|
19
|
+
h2 {
|
|
20
|
+
font-size: 11px;
|
|
21
|
+
color: #888;
|
|
22
|
+
text-transform: uppercase;
|
|
23
|
+
letter-spacing: 1px;
|
|
24
|
+
margin: 20px 0 8px;
|
|
25
|
+
}
|
|
26
|
+
p {
|
|
27
|
+
color: #666;
|
|
28
|
+
font-size: 11px;
|
|
29
|
+
margin: 4px 0;
|
|
30
|
+
}
|
|
31
|
+
ul {
|
|
32
|
+
list-style: none;
|
|
33
|
+
padding: 0;
|
|
34
|
+
margin: 0;
|
|
35
|
+
}
|
|
36
|
+
li {
|
|
37
|
+
margin: 3px 0;
|
|
38
|
+
}
|
|
39
|
+
button {
|
|
40
|
+
background: #1f2335;
|
|
41
|
+
color: #7aa2f7;
|
|
42
|
+
border: 1px solid #2a2a33;
|
|
43
|
+
border-radius: 4px;
|
|
44
|
+
font: inherit;
|
|
45
|
+
font-size: 11px;
|
|
46
|
+
cursor: pointer;
|
|
47
|
+
padding: 1px 8px;
|
|
48
|
+
}
|
|
49
|
+
button:hover {
|
|
50
|
+
background: #24283b;
|
|
51
|
+
}
|
|
52
|
+
button:disabled {
|
|
53
|
+
color: #555;
|
|
54
|
+
cursor: default;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.layout {
|
|
58
|
+
display: grid;
|
|
59
|
+
grid-template-columns: 380px 1fr;
|
|
60
|
+
min-height: 100vh;
|
|
61
|
+
}
|
|
62
|
+
.sidebar {
|
|
63
|
+
padding: 20px;
|
|
64
|
+
border-right: 1px solid #2a2a33;
|
|
65
|
+
overflow-y: auto;
|
|
66
|
+
max-height: 100vh;
|
|
67
|
+
box-sizing: border-box;
|
|
68
|
+
}
|
|
69
|
+
.main {
|
|
70
|
+
padding: 20px;
|
|
71
|
+
overflow-y: auto;
|
|
72
|
+
max-height: 100vh;
|
|
73
|
+
box-sizing: border-box;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.task {
|
|
77
|
+
margin: 1px 0;
|
|
78
|
+
}
|
|
79
|
+
.task > .row {
|
|
80
|
+
display: flex;
|
|
81
|
+
align-items: center;
|
|
82
|
+
gap: 6px;
|
|
83
|
+
padding: 1px 0;
|
|
84
|
+
}
|
|
85
|
+
.task .run {
|
|
86
|
+
visibility: hidden;
|
|
87
|
+
padding: 0 6px;
|
|
88
|
+
}
|
|
89
|
+
.task > .row:hover .run {
|
|
90
|
+
visibility: visible;
|
|
91
|
+
}
|
|
92
|
+
.task .dot {
|
|
93
|
+
width: 8px;
|
|
94
|
+
height: 8px;
|
|
95
|
+
border-radius: 50%;
|
|
96
|
+
background: #444;
|
|
97
|
+
flex: none;
|
|
98
|
+
}
|
|
99
|
+
.task .dot.pass {
|
|
100
|
+
background: #9ece6a;
|
|
101
|
+
}
|
|
102
|
+
.task .dot.fail {
|
|
103
|
+
background: #f7768e;
|
|
104
|
+
}
|
|
105
|
+
.task .dot.run {
|
|
106
|
+
background: #e0af68;
|
|
107
|
+
animation: pulse 1s infinite;
|
|
108
|
+
}
|
|
109
|
+
.task .name {
|
|
110
|
+
overflow: hidden;
|
|
111
|
+
text-overflow: ellipsis;
|
|
112
|
+
white-space: nowrap;
|
|
113
|
+
}
|
|
114
|
+
.task .time {
|
|
115
|
+
color: #565f89;
|
|
116
|
+
font-size: 11px;
|
|
117
|
+
margin-left: auto;
|
|
118
|
+
flex: none;
|
|
119
|
+
}
|
|
120
|
+
.task .children {
|
|
121
|
+
margin-left: 14px;
|
|
122
|
+
border-left: 1px solid #1f2335;
|
|
123
|
+
padding-left: 8px;
|
|
124
|
+
}
|
|
125
|
+
.task .error {
|
|
126
|
+
color: #f7768e;
|
|
127
|
+
font-size: 11px;
|
|
128
|
+
margin: 2px 0 4px 14px;
|
|
129
|
+
white-space: pre-wrap;
|
|
130
|
+
}
|
|
131
|
+
#run-state {
|
|
132
|
+
color: #e0af68;
|
|
133
|
+
font-size: 11px;
|
|
134
|
+
margin-left: 8px;
|
|
135
|
+
text-transform: none;
|
|
136
|
+
letter-spacing: 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.session {
|
|
140
|
+
margin-bottom: 20px;
|
|
141
|
+
border: 1px solid #2a2a33;
|
|
142
|
+
border-radius: 6px;
|
|
143
|
+
overflow: hidden;
|
|
144
|
+
width: fit-content;
|
|
145
|
+
}
|
|
146
|
+
.session > .bar {
|
|
147
|
+
display: flex;
|
|
148
|
+
justify-content: space-between;
|
|
149
|
+
gap: 16px;
|
|
150
|
+
padding: 6px 10px;
|
|
151
|
+
background: #16161e;
|
|
152
|
+
font-size: 12px;
|
|
153
|
+
}
|
|
154
|
+
.session .status {
|
|
155
|
+
color: #9ece6a;
|
|
156
|
+
}
|
|
157
|
+
.session.done .status {
|
|
158
|
+
color: #888;
|
|
159
|
+
}
|
|
160
|
+
.session.failed .status {
|
|
161
|
+
color: #f7768e;
|
|
162
|
+
}
|
|
163
|
+
.session > .body {
|
|
164
|
+
padding: 8px;
|
|
165
|
+
}
|
|
166
|
+
#empty {
|
|
167
|
+
color: #565f89;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.terminal-view {
|
|
171
|
+
display: flex;
|
|
172
|
+
flex-direction: column;
|
|
173
|
+
height: 100vh;
|
|
174
|
+
box-sizing: border-box;
|
|
175
|
+
padding: 12px;
|
|
176
|
+
gap: 8px;
|
|
177
|
+
}
|
|
178
|
+
.terminal-view header {
|
|
179
|
+
color: #888;
|
|
180
|
+
font-size: 12px;
|
|
181
|
+
display: flex;
|
|
182
|
+
justify-content: space-between;
|
|
183
|
+
flex: none;
|
|
184
|
+
}
|
|
185
|
+
.terminal-view .terminal-host {
|
|
186
|
+
flex: 1;
|
|
187
|
+
min-height: 0;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
@keyframes pulse {
|
|
191
|
+
50% {
|
|
192
|
+
opacity: 0.4;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
|
5
|
+
"module": "esnext",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noUncheckedIndexedAccess": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"isolatedModules": true,
|
|
12
|
+
"verbatimModuleSyntax": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["."]
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// End-to-end testing for terminal apps: launch any command in a PTY attached
|
|
2
|
+
// to a real terminal emulator engine — Ghostty's VT core (WebAssembly) or
|
|
3
|
+
// xterm.js — and drive it with Playwright-style locators, key presses, and
|
|
4
|
+
// Vitest matchers. The engines and the PTY are loaded on demand.
|
|
5
|
+
//
|
|
6
|
+
// Published as `@alchemy.run/sigil/testing`; emulator engines remain lazy.
|
|
7
|
+
export type { Emulator, EmulatorCell, EmulatorName } from "#/testing/emulators.ts";
|
|
8
|
+
export { createEmulator } from "#/testing/emulators.ts";
|
|
9
|
+
export { keyToSequence } from "#/testing/keys.ts";
|
|
10
|
+
export { terminalMatchers } from "#/testing/matchers.ts";
|
|
11
|
+
export type {
|
|
12
|
+
LaunchOptions,
|
|
13
|
+
TerminalApp,
|
|
14
|
+
TerminalLocator,
|
|
15
|
+
WaitForOptions,
|
|
16
|
+
} from "#/testing/terminal.ts";
|
|
17
|
+
export { launchTerminal } from "#/testing/terminal.ts";
|
|
18
|
+
export type {
|
|
19
|
+
ExplorerEntry,
|
|
20
|
+
ServeExplorerOptions,
|
|
21
|
+
ServeTerminalOptions,
|
|
22
|
+
TerminalServer,
|
|
23
|
+
} from "#/testing/browser.ts";
|
|
24
|
+
export { serveExplorer, serveTerminal } from "#/testing/browser.ts";
|
|
25
|
+
export type { SerializedTask, TestEngine } from "#/testing/vitest.ts";
|
|
26
|
+
export { createVitestEngine } from "#/testing/vitest.ts";
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Key names → escape sequences, Playwright-style: press("Enter"),
|
|
2
|
+
// press("Ctrl+C"), press("Shift+Tab"), or any single character.
|
|
3
|
+
import { CSI, ESC } from "#/ansi/escapes.ts";
|
|
4
|
+
|
|
5
|
+
const keySequences: Record<string, string> = {
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
7
|
+
Enter: "\r",
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
9
|
+
Tab: "\t",
|
|
10
|
+
"Shift+Tab": `${CSI}Z`,
|
|
11
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
12
|
+
Escape: ESC,
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
14
|
+
Backspace: "\u007F",
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
16
|
+
Delete: `${CSI}3~`,
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
18
|
+
Space: " ",
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
20
|
+
ArrowUp: `${CSI}A`,
|
|
21
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
22
|
+
ArrowDown: `${CSI}B`,
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
24
|
+
ArrowRight: `${CSI}C`,
|
|
25
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
26
|
+
ArrowLeft: `${CSI}D`,
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
28
|
+
Home: `${CSI}H`,
|
|
29
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
30
|
+
End: `${CSI}F`,
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
32
|
+
PageUp: `${CSI}5~`,
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
34
|
+
PageDown: `${CSI}6~`,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const keyToSequence = (key: string): string => {
|
|
38
|
+
const sequence = keySequences[key];
|
|
39
|
+
if (sequence !== undefined) {
|
|
40
|
+
return sequence;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const ctrl = /^Ctrl\+([a-z])$/i.exec(key);
|
|
44
|
+
if (ctrl) {
|
|
45
|
+
return String.fromCharCode(ctrl[1]!.toLowerCase().charCodeAt(0) - 96);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (Array.from(key).length === 1) {
|
|
49
|
+
return key;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
throw new Error(
|
|
53
|
+
`Unknown key "${key}". Use a named key (${Object.keys(keySequences).join(", ")}), ` +
|
|
54
|
+
`"Ctrl+<letter>", or a single character.`,
|
|
55
|
+
);
|
|
56
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Mirrors a harness-launched terminal session to a live-view hub (the
|
|
2
|
+
// explorer's /live page) over WebSocket, so e2e runs can be watched in the
|
|
3
|
+
// browser while they execute — Playwright-UI style.
|
|
4
|
+
//
|
|
5
|
+
// Activated by the SIGIL_LIVE_URL environment variable (the explorer sets it
|
|
6
|
+
// for the Vitest UI it spawns). Mirroring is fire-and-forget: a missing or
|
|
7
|
+
// dead hub never affects the test.
|
|
8
|
+
|
|
9
|
+
type LiveMessage =
|
|
10
|
+
| { type: "start"; id: string; title: string; columns: number; rows: number }
|
|
11
|
+
| { type: "title"; id: string; title: string }
|
|
12
|
+
| { type: "data"; id: string; data: string }
|
|
13
|
+
| { type: "end"; id: string; code: number | undefined };
|
|
14
|
+
|
|
15
|
+
export type LiveClient = {
|
|
16
|
+
send: (message: LiveMessage) => void;
|
|
17
|
+
close: () => void;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
Connects to the live hub named by `SIGIL_LIVE_URL`, or returns `undefined`
|
|
22
|
+
when none is configured. Messages sent before the socket opens are queued.
|
|
23
|
+
*/
|
|
24
|
+
export const connectLiveClient = (): LiveClient | undefined => {
|
|
25
|
+
const base = process.env["SIGIL_LIVE_URL"];
|
|
26
|
+
if (!base) {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let socket: WebSocket | undefined;
|
|
31
|
+
let failed = false;
|
|
32
|
+
const queue: string[] = [];
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const url = new URL("/live/ingest", base);
|
|
36
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
37
|
+
socket = new WebSocket(url);
|
|
38
|
+
} catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
socket.addEventListener("open", () => {
|
|
43
|
+
for (const message of queue) {
|
|
44
|
+
socket?.send(message);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
queue.length = 0;
|
|
48
|
+
});
|
|
49
|
+
socket.addEventListener("error", () => {
|
|
50
|
+
failed = true;
|
|
51
|
+
queue.length = 0;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
send: (message) => {
|
|
56
|
+
if (failed) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const encoded = JSON.stringify(message);
|
|
61
|
+
if (socket?.readyState === WebSocket.OPEN) {
|
|
62
|
+
socket.send(encoded);
|
|
63
|
+
} else if (socket?.readyState === WebSocket.CONNECTING) {
|
|
64
|
+
queue.push(encoded);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
close: () => {
|
|
68
|
+
try {
|
|
69
|
+
socket?.close();
|
|
70
|
+
} catch {}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
The current test name when running inside Vitest, for labeling the session.
|
|
77
|
+
*/
|
|
78
|
+
export const currentTestName = async (): Promise<string | undefined> => {
|
|
79
|
+
try {
|
|
80
|
+
const { expect } = await import("vitest");
|
|
81
|
+
return expect.getState().currentTestName ?? undefined;
|
|
82
|
+
} catch {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Playwright-style async matchers for Vitest. Register once per test file:
|
|
2
|
+
//
|
|
3
|
+
// import { expect } from "vitest";
|
|
4
|
+
// import { terminalMatchers } from "#/testing/index.ts";
|
|
5
|
+
// expect.extend(terminalMatchers);
|
|
6
|
+
//
|
|
7
|
+
// await expect(app.getByText("Home")).toBeVisible();
|
|
8
|
+
// await expect(app).toContainScreenText("Users");
|
|
9
|
+
// Type-only: makes the module augmentation below attach to vitest's types.
|
|
10
|
+
import type {} from "vitest";
|
|
11
|
+
|
|
12
|
+
import { type TerminalApp, type TerminalLocator } from "#/testing/terminal.ts";
|
|
13
|
+
|
|
14
|
+
type MatcherResult = {
|
|
15
|
+
pass: boolean;
|
|
16
|
+
message: () => string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type MatcherContext = {
|
|
20
|
+
isNot: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
declare module "vitest" {
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- must match vitest's own type parameter exactly
|
|
25
|
+
interface Matchers<T = any> {
|
|
26
|
+
toBeVisible(options?: { timeout?: number }): Promise<T>;
|
|
27
|
+
toContainScreenText(text: string | RegExp, options?: { timeout?: number }): Promise<T>;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const terminalMatchers = {
|
|
32
|
+
/**
|
|
33
|
+
Waits for the locator's text to appear on screen (or to disappear when
|
|
34
|
+
negated with `.not`).
|
|
35
|
+
*/
|
|
36
|
+
async toBeVisible(
|
|
37
|
+
this: MatcherContext,
|
|
38
|
+
locator: TerminalLocator,
|
|
39
|
+
options: { timeout?: number } = {},
|
|
40
|
+
): Promise<MatcherResult> {
|
|
41
|
+
const state = this.isNot ? "hidden" : "visible";
|
|
42
|
+
try {
|
|
43
|
+
await locator.waitFor({ ...options, state });
|
|
44
|
+
return {
|
|
45
|
+
// `.not` inverts `pass`, so reaching the desired state must report
|
|
46
|
+
// the polarity that makes the assertion succeed either way.
|
|
47
|
+
pass: !this.isNot,
|
|
48
|
+
message: () => `expected ${locator.description} to be ${state} — and it is`,
|
|
49
|
+
};
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return {
|
|
52
|
+
pass: this.isNot,
|
|
53
|
+
message: () => (error as Error).message,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
Waits for the given text to appear anywhere on the visible screen.
|
|
60
|
+
*/
|
|
61
|
+
async toContainScreenText(
|
|
62
|
+
this: MatcherContext,
|
|
63
|
+
app: TerminalApp,
|
|
64
|
+
text: string | RegExp,
|
|
65
|
+
options: { timeout?: number } = {},
|
|
66
|
+
): Promise<MatcherResult> {
|
|
67
|
+
const locator = app.getByText(text);
|
|
68
|
+
return terminalMatchers.toBeVisible.call(this, locator, options);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
|
|
3
|
+
import type { ColorProfile } from "#/screen/color-profile.ts";
|
|
4
|
+
import type { Screen } from "#/screen/screen.ts";
|
|
5
|
+
import { serializeScreen } from "#/screen/serialize.ts";
|
|
6
|
+
|
|
7
|
+
export class VirtualOutput extends EventEmitter {
|
|
8
|
+
columns: number;
|
|
9
|
+
rows: number;
|
|
10
|
+
isTTY: boolean;
|
|
11
|
+
writable = true;
|
|
12
|
+
writableEnded = false;
|
|
13
|
+
destroyed = false;
|
|
14
|
+
#chunks: string[] = [];
|
|
15
|
+
|
|
16
|
+
constructor(options: { columns?: number; rows?: number; isTTY?: boolean } = {}) {
|
|
17
|
+
super();
|
|
18
|
+
this.columns = options.columns ?? 80;
|
|
19
|
+
this.rows = options.rows ?? 24;
|
|
20
|
+
this.isTTY = options.isTTY ?? true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
write(
|
|
24
|
+
chunk: Uint8Array | string,
|
|
25
|
+
encodingOrCallback?: BufferEncoding | (() => void),
|
|
26
|
+
callback?: () => void,
|
|
27
|
+
): boolean {
|
|
28
|
+
this.#chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString());
|
|
29
|
+
const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback;
|
|
30
|
+
queueMicrotask(() => done?.());
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
output(): string {
|
|
35
|
+
return this.#chunks.join("");
|
|
36
|
+
}
|
|
37
|
+
writes(): readonly string[] {
|
|
38
|
+
return this.#chunks;
|
|
39
|
+
}
|
|
40
|
+
reset(): void {
|
|
41
|
+
this.#chunks = [];
|
|
42
|
+
}
|
|
43
|
+
resize(columns: number, rows: number): void {
|
|
44
|
+
this.columns = columns;
|
|
45
|
+
this.rows = rows;
|
|
46
|
+
this.emit("resize");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type RecordedFrame = {
|
|
51
|
+
readonly timestamp: number;
|
|
52
|
+
readonly screen: Screen;
|
|
53
|
+
readonly output: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export class FrameRecorder {
|
|
57
|
+
#frames: RecordedFrame[] = [];
|
|
58
|
+
readonly colorProfile: ColorProfile;
|
|
59
|
+
constructor(colorProfile: ColorProfile = "truecolor") {
|
|
60
|
+
this.colorProfile = colorProfile;
|
|
61
|
+
}
|
|
62
|
+
record(screen: Screen, timestamp = performance.now()): RecordedFrame {
|
|
63
|
+
const frame = {
|
|
64
|
+
timestamp,
|
|
65
|
+
screen,
|
|
66
|
+
output: serializeScreen(screen, { colorProfile: this.colorProfile }),
|
|
67
|
+
};
|
|
68
|
+
this.#frames.push(frame);
|
|
69
|
+
return frame;
|
|
70
|
+
}
|
|
71
|
+
frames(): readonly RecordedFrame[] {
|
|
72
|
+
return this.#frames;
|
|
73
|
+
}
|
|
74
|
+
clear(): void {
|
|
75
|
+
this.#frames = [];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const screenText = (screen: Screen): string =>
|
|
80
|
+
screen
|
|
81
|
+
.toRows()
|
|
82
|
+
.map((row) =>
|
|
83
|
+
row
|
|
84
|
+
.filter((cell) => cell.width > 0)
|
|
85
|
+
.map((cell) => cell.grapheme)
|
|
86
|
+
.join("")
|
|
87
|
+
.trimEnd(),
|
|
88
|
+
)
|
|
89
|
+
.join("\n");
|
|
90
|
+
|
|
91
|
+
export function assertScreenText(screen: Screen, expected: string): void {
|
|
92
|
+
const actual = screenText(screen);
|
|
93
|
+
if (actual !== expected) throw new Error(`Expected screen:\n${expected}\n\nReceived:\n${actual}`);
|
|
94
|
+
}
|