@rigkit/provider-freestyle 0.1.8

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.
@@ -0,0 +1,182 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createFreestyleTerminalSession } from "./terminal-session.ts";
3
+
4
+ describe("Freestyle terminal session", () => {
5
+ test("serves a wterm page and resolves after the user finishes", async () => {
6
+ const session = createFreestyleTerminalSession({
7
+ nodePath: "login",
8
+ title: "GitHub auth",
9
+ command: localInteractiveShell,
10
+ remoteCommand: "printf interactive-ready",
11
+ instructions: "Authenticate GitHub inside the VM.",
12
+ });
13
+
14
+ try {
15
+ const page = await fetch(session.url);
16
+ const html = await page.text();
17
+
18
+ expect(page.status).toBe(200);
19
+ expect(html).toContain("GitHub auth");
20
+ expect(html).toContain("Authenticate GitHub inside the VM.");
21
+ expect(html).toContain("printf interactive-ready");
22
+ expect(html).toContain("@wterm/dom");
23
+ expect(html).toContain("@wterm/ghostty");
24
+ expect(html).toContain("terminal-window");
25
+ expect(html).toContain("light red");
26
+ expect(html).toContain("Finished");
27
+ expect(html).toContain("document.addEventListener(\"keydown\"");
28
+ expect(html).toContain("{ capture: true }");
29
+ expect(html).toContain("terminalEl.contains(target)");
30
+ expect(html).toContain("user-select: text");
31
+ expect(html).toContain("keyEventToTerminalInput");
32
+ expect(html).toContain("sendTerminalInput(data)");
33
+ const startupInput = readStartupInput(html);
34
+ expect(startupInput).toBe("printf interactive-ready\n");
35
+
36
+ const messages: unknown[] = [];
37
+ const socketUrl = new URL(session.url.replace("/?", "/terminal?"));
38
+ socketUrl.protocol = "ws:";
39
+ const socket = new WebSocket(socketUrl);
40
+ socket.addEventListener("message", (event) => {
41
+ messages.push(JSON.parse(String(event.data)));
42
+ });
43
+ await sendOnOpen(socket, startupInput);
44
+
45
+ await waitFor(() =>
46
+ messages.some((message) =>
47
+ isMessage(message, "output") && message.data.includes("interactive-ready")
48
+ ),
49
+ );
50
+ await waitFor(() =>
51
+ messages.some((message) =>
52
+ isMessage(message, "status") && message.status === "Running printf interactive-ready" && message.canFinish
53
+ ),
54
+ );
55
+
56
+ socket.send(JSON.stringify({ type: "finish" }));
57
+ await expect(session.completed).resolves.toEqual({ finished: true });
58
+ socket.close();
59
+ } finally {
60
+ session.stop();
61
+ }
62
+ });
63
+
64
+ test("keeps the terminal open until the user finishes", async () => {
65
+ const session = createFreestyleTerminalSession({
66
+ nodePath: "login",
67
+ title: "Manual auth",
68
+ command: localInteractiveShell,
69
+ remoteCommand: "printf manual-ready",
70
+ });
71
+
72
+ let resolved = false;
73
+ session.completed.then(() => {
74
+ resolved = true;
75
+ });
76
+
77
+ try {
78
+ const messages: unknown[] = [];
79
+ const socketUrl = new URL(session.url.replace("/?", "/terminal?"));
80
+ socketUrl.protocol = "ws:";
81
+ const socket = new WebSocket(socketUrl);
82
+ socket.addEventListener("message", (event) => {
83
+ messages.push(JSON.parse(String(event.data)));
84
+ });
85
+ const page = await fetch(session.url);
86
+ const startupInput = readStartupInput(await page.text());
87
+ await sendOnOpen(socket, startupInput);
88
+
89
+ await waitFor(() =>
90
+ messages.some((message) =>
91
+ isMessage(message, "status") && message.status === "Running printf manual-ready" && message.canFinish
92
+ ),
93
+ );
94
+ await new Promise((resolve) => setTimeout(resolve, 25));
95
+ expect(resolved).toBe(false);
96
+
97
+ socket.send(JSON.stringify({ type: "finish" }));
98
+ await expect(session.completed).resolves.toEqual({ finished: true });
99
+ socket.close();
100
+ } finally {
101
+ session.stop();
102
+ }
103
+ });
104
+
105
+ test("answers cursor position reports for terminal UI prompts", async () => {
106
+ const session = createFreestyleTerminalSession({
107
+ nodePath: "prompt",
108
+ title: "Prompt UI",
109
+ command: cursorPositionProbe,
110
+ });
111
+
112
+ try {
113
+ const messages: unknown[] = [];
114
+ const socketUrl = new URL(session.url.replace("/?", "/terminal?"));
115
+ socketUrl.protocol = "ws:";
116
+ const socket = new WebSocket(socketUrl);
117
+ socket.addEventListener("message", (event) => {
118
+ messages.push(JSON.parse(String(event.data)));
119
+ });
120
+
121
+ await waitForSocketOpen(socket);
122
+
123
+ await waitFor(() =>
124
+ messages.some((message) =>
125
+ isMessage(message, "output") && message.data.includes("CPR:1b5b32383b31303052")
126
+ ),
127
+ );
128
+
129
+ socket.send(JSON.stringify({ type: "finish" }));
130
+ await expect(session.completed).resolves.toEqual({ finished: true });
131
+ socket.close();
132
+ } finally {
133
+ session.stop();
134
+ }
135
+ });
136
+ });
137
+
138
+ const localInteractiveShell = "bash --noprofile --norc -i";
139
+ const cursorPositionProbe = "node -e " + JSON.stringify([
140
+ "process.stdout.write('\\x1b[6n');",
141
+ "process.stdin.once('data', (chunk) => {",
142
+ " process.stdout.write('CPR:' + Buffer.from(chunk).toString('hex') + '\\n');",
143
+ " process.exit(0);",
144
+ "});",
145
+ ].join(""));
146
+
147
+ function readStartupInput(html: string): string {
148
+ const match = /const startupInput = (.*);/.exec(html);
149
+ if (!match) throw new Error("startup input was not rendered");
150
+ const value = JSON.parse(match[1]!) as unknown;
151
+ if (typeof value !== "string") throw new Error("startup input was not a string");
152
+ return value;
153
+ }
154
+
155
+ async function sendOnOpen(socket: WebSocket, data: string): Promise<void> {
156
+ await waitForSocketOpen(socket);
157
+ socket.send(JSON.stringify({ type: "input", data }));
158
+ }
159
+
160
+ async function waitForSocketOpen(socket: WebSocket): Promise<void> {
161
+ if (socket.readyState === WebSocket.OPEN) return;
162
+ await new Promise<void>((resolve) => {
163
+ socket.addEventListener("open", () => {
164
+ resolve();
165
+ }, { once: true });
166
+ });
167
+ }
168
+
169
+ function isMessage(
170
+ value: unknown,
171
+ type: string,
172
+ ): value is { type: string; data: string; status: string; canFinish?: boolean } {
173
+ return Boolean(value && typeof value === "object" && (value as { type?: unknown }).type === type);
174
+ }
175
+
176
+ async function waitFor(assertion: () => boolean): Promise<void> {
177
+ const started = Date.now();
178
+ while (!assertion()) {
179
+ if (Date.now() - started > 1_000) throw new Error("Timed out waiting for terminal event");
180
+ await new Promise((resolve) => setTimeout(resolve, 10));
181
+ }
182
+ }