@rigkit/provider-freestyle 0.2.3 → 0.2.5

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.
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import { createFreestyleTerminalSession } from "./terminal-session.ts";
3
3
 
4
4
  describe("Freestyle terminal session", () => {
5
- test("serves a wterm page and resolves after the user finishes", async () => {
5
+ test("serves an xterm page and resolves after the user finishes", async () => {
6
6
  const session = createFreestyleTerminalSession({
7
7
  nodePath: "login",
8
8
  title: "GitHub auth",
@@ -19,17 +19,30 @@ describe("Freestyle terminal session", () => {
19
19
  expect(html).toContain("GitHub auth");
20
20
  expect(html).toContain("Authenticate GitHub inside the VM.");
21
21
  expect(html).toContain("printf interactive-ready");
22
- expect(html).toContain("@wterm/dom");
23
- expect(html).toContain("@wterm/ghostty");
22
+ expect(html).toContain("@xterm/xterm");
23
+ expect(html).toContain("@xterm/addon-fit");
24
+ expect(html).toContain("@xterm/addon-web-links");
24
25
  expect(html).toContain("terminal-window");
25
- expect(html).toContain("light red");
26
- expect(html).toContain("Finished");
26
+ expect(html).toContain("freestyle.sh");
27
+ expect(html).toContain("Complete task");
27
28
  expect(html).toContain("document.addEventListener(\"keydown\"");
28
29
  expect(html).toContain("{ capture: true }");
29
30
  expect(html).toContain("terminalEl.contains(target)");
30
31
  expect(html).toContain("user-select: text");
31
32
  expect(html).toContain("keyEventToTerminalInput");
32
33
  expect(html).toContain("sendTerminalInput(data)");
34
+ expect(html).toContain("terminalUrlFromEvent");
35
+ expect(html).toContain("expandKnownTerminalUrl");
36
+ expect(html).toContain("openPendingBrowserPrompt");
37
+ expect(html).toContain("window.open(normalized");
38
+ expect(html).toContain("document.addEventListener(\"paste\"");
39
+ expect(html).toContain("term-input-proxy");
40
+ expect(html).toContain("onPointerDownCapture");
41
+ expect(html).toContain("onBeforeInput");
42
+ expect(html).toContain("sendTextInputEventToTerminal");
43
+ expect(html).toContain("sendPasteEventToTerminal");
44
+ expect(html).not.toContain("term-send-form");
45
+ expect(html).not.toContain("Send to terminal");
33
46
  const startupInput = readStartupInput(html);
34
47
  expect(startupInput).toBe("printf interactive-ready\n");
35
48
 
@@ -102,6 +115,46 @@ describe("Freestyle terminal session", () => {
102
115
  }
103
116
  });
104
117
 
118
+ test("can allow finishing while the terminal process is still running", async () => {
119
+ const session = createFreestyleTerminalSession({
120
+ nodePath: "login",
121
+ title: "Keep-open command",
122
+ command: "sleep 5",
123
+ displayCommand: "sleep 5",
124
+ canFinishWhileRunning: true,
125
+ });
126
+
127
+ let resolved = false;
128
+ session.completed.then(() => {
129
+ resolved = true;
130
+ });
131
+
132
+ try {
133
+ const messages: unknown[] = [];
134
+ const socketUrl = new URL(session.url.replace("/?", "/terminal?"));
135
+ socketUrl.protocol = "ws:";
136
+ const socket = new WebSocket(socketUrl);
137
+ socket.addEventListener("message", (event) => {
138
+ messages.push(JSON.parse(String(event.data)));
139
+ });
140
+
141
+ await waitForSocketOpen(socket);
142
+ await waitFor(() =>
143
+ messages.some((message) =>
144
+ isMessage(message, "status") && Boolean(message.canFinish)
145
+ ),
146
+ );
147
+ await new Promise((resolve) => setTimeout(resolve, 25));
148
+ expect(resolved).toBe(false);
149
+
150
+ socket.send(JSON.stringify({ type: "finish" }));
151
+ await expect(session.completed).resolves.toEqual({ finished: true });
152
+ socket.close();
153
+ } finally {
154
+ session.stop();
155
+ }
156
+ });
157
+
105
158
  test("answers cursor position reports for terminal UI prompts", async () => {
106
159
  const session = createFreestyleTerminalSession({
107
160
  nodePath: "prompt",
@@ -133,16 +186,195 @@ describe("Freestyle terminal session", () => {
133
186
  session.stop();
134
187
  }
135
188
  });
189
+
190
+ test("forwards browser terminal input to process stdin", async () => {
191
+ const session = createFreestyleTerminalSession({
192
+ nodePath: "prompt",
193
+ title: "Input prompt",
194
+ command: stdinEchoProbe,
195
+ });
196
+
197
+ try {
198
+ const messages: unknown[] = [];
199
+ const socketUrl = new URL(session.url.replace("/?", "/terminal?"));
200
+ socketUrl.protocol = "ws:";
201
+ const socket = new WebSocket(socketUrl);
202
+ socket.addEventListener("message", (event) => {
203
+ messages.push(JSON.parse(String(event.data)));
204
+ });
205
+
206
+ await waitForSocketOpen(socket);
207
+ socket.send(JSON.stringify({ type: "input", data: "abc123\n" }));
208
+
209
+ await waitFor(() =>
210
+ messages.some((message) =>
211
+ isMessage(message, "output") && message.data.includes("STDIN:abc123")
212
+ ),
213
+ );
214
+
215
+ socket.send(JSON.stringify({ type: "finish" }));
216
+ await expect(session.completed).resolves.toEqual({ finished: true });
217
+ socket.close();
218
+ } finally {
219
+ session.stop();
220
+ }
221
+ });
222
+
223
+ test("drops terminal-generated string control responses from browser input", async () => {
224
+ const session = createFreestyleTerminalSession({
225
+ nodePath: "prompt",
226
+ title: "Input prompt",
227
+ command: stdinHexProbe,
228
+ });
229
+
230
+ try {
231
+ const messages: unknown[] = [];
232
+ const socketUrl = new URL(session.url.replace("/?", "/terminal?"));
233
+ socketUrl.protocol = "ws:";
234
+ const socket = new WebSocket(socketUrl);
235
+ socket.addEventListener("message", (event) => {
236
+ messages.push(JSON.parse(String(event.data)));
237
+ });
238
+
239
+ await waitForSocketOpen(socket);
240
+ socket.send(JSON.stringify({ type: "input", data: "\x1b]10;rgb:ffff/ffff/ffff\x07" }));
241
+ socket.send(JSON.stringify({ type: "input", data: "y\n" }));
242
+
243
+ await waitFor(() =>
244
+ messages.some((message) =>
245
+ isMessage(message, "output") && message.data.includes("STDINHEX:790a")
246
+ ),
247
+ );
248
+
249
+ socket.send(JSON.stringify({ type: "finish" }));
250
+ await expect(session.completed).resolves.toEqual({ finished: true });
251
+ socket.close();
252
+ } finally {
253
+ session.stop();
254
+ }
255
+ });
256
+
257
+ test("runs interactive commands with a TTY stdin", async () => {
258
+ const session = createFreestyleTerminalSession({
259
+ nodePath: "prompt",
260
+ title: "TTY probe",
261
+ command: ttyProbe,
262
+ });
263
+
264
+ try {
265
+ const messages: unknown[] = [];
266
+ const socketUrl = new URL(session.url.replace("/?", "/terminal?"));
267
+ socketUrl.protocol = "ws:";
268
+ const socket = new WebSocket(socketUrl);
269
+ socket.addEventListener("message", (event) => {
270
+ messages.push(JSON.parse(String(event.data)));
271
+ });
272
+
273
+ await waitForSocketOpen(socket);
274
+
275
+ await waitFor(() =>
276
+ messages.some((message) =>
277
+ isMessage(message, "output") && message.data.includes("TTY:true")
278
+ ),
279
+ );
280
+
281
+ socket.send(JSON.stringify({ type: "finish" }));
282
+ await expect(session.completed).resolves.toEqual({ finished: true });
283
+ socket.close();
284
+ } finally {
285
+ session.stop();
286
+ }
287
+ });
288
+
289
+ test("asks the host to open complete browser auth URLs printed by the terminal", async () => {
290
+ const opened: string[] = [];
291
+ const authUrl = "https://claude.com/cai/oauth/authorize?code=true&state=abc";
292
+ const session = createFreestyleTerminalSession({
293
+ nodePath: "login",
294
+ title: "Claude auth",
295
+ command: "printf %s " + JSON.stringify(`Opening browser to sign in...\nIf the browser didn't open, visit: ${authUrl}\nPaste code here if prompted > `),
296
+ openExternalTarget: (target) => opened.push(target),
297
+ });
298
+
299
+ try {
300
+ const socketUrl = new URL(session.url.replace("/?", "/terminal?"));
301
+ socketUrl.protocol = "ws:";
302
+ const socket = new WebSocket(socketUrl);
303
+ await waitForSocketOpen(socket);
304
+
305
+ await waitFor(() => opened.includes(authUrl));
306
+ socket.send(JSON.stringify({ type: "finish" }));
307
+ await expect(session.completed).resolves.toEqual({ finished: true });
308
+ socket.close();
309
+ } finally {
310
+ session.stop();
311
+ }
312
+ });
313
+
314
+ test("does not open partial browser auth URLs while output is still streaming", async () => {
315
+ const opened: string[] = [];
316
+ const authUrl = "https://claude.com/cai/oauth/authorize?code=true&state=abc";
317
+ const session = createFreestyleTerminalSession({
318
+ nodePath: "login",
319
+ title: "Claude auth",
320
+ command: streamedAuthUrlProbe,
321
+ openExternalTarget: (target) => opened.push(target),
322
+ });
323
+
324
+ try {
325
+ const socketUrl = new URL(session.url.replace("/?", "/terminal?"));
326
+ socketUrl.protocol = "ws:";
327
+ const socket = new WebSocket(socketUrl);
328
+ await waitForSocketOpen(socket);
329
+
330
+ await new Promise((resolve) => setTimeout(resolve, 750));
331
+ expect(opened).toEqual([]);
332
+ await waitFor(() => opened.includes(authUrl), 2_500);
333
+ socket.send(JSON.stringify({ type: "finish" }));
334
+ await expect(session.completed).resolves.toEqual({ finished: true });
335
+ socket.close();
336
+ } finally {
337
+ session.stop();
338
+ }
339
+ });
136
340
  });
137
341
 
138
342
  const localInteractiveShell = "bash --noprofile --norc -i";
139
343
  const cursorPositionProbe = "node -e " + JSON.stringify([
344
+ "process.stdin.setRawMode?.(true);",
140
345
  "process.stdout.write('\\x1b[6n');",
141
346
  "process.stdin.once('data', (chunk) => {",
142
347
  " process.stdout.write('CPR:' + Buffer.from(chunk).toString('hex') + '\\n');",
143
348
  " process.exit(0);",
144
349
  "});",
145
350
  ].join(""));
351
+ const stdinEchoProbe = "node -e " + JSON.stringify([
352
+ "process.stdin.once('data', (chunk) => {",
353
+ " process.stdout.write('STDIN:' + chunk.toString('utf8').trim() + '\\n');",
354
+ " process.exit(0);",
355
+ "});",
356
+ ].join(""));
357
+ const stdinHexProbe = "node -e " + JSON.stringify([
358
+ "process.stdin.setRawMode?.(true);",
359
+ "let data = Buffer.alloc(0);",
360
+ "process.stdin.on('data', (chunk) => {",
361
+ " data = Buffer.concat([data, chunk]);",
362
+ " if (data.includes(10)) {",
363
+ " process.stdout.write('STDINHEX:' + data.toString('hex') + '\\n');",
364
+ " process.exit(0);",
365
+ " }",
366
+ "});",
367
+ ].join(""));
368
+ const ttyProbe = "node -e " + JSON.stringify([
369
+ "process.stdout.write('TTY:' + Boolean(process.stdin.isTTY) + '\\n');",
370
+ "process.exit(0);",
371
+ ].join(""));
372
+ const streamedAuthUrlProbe = "node -e " + JSON.stringify([
373
+ "process.stdout.write(\"Opening browser to sign in...\\nIf the browser didn't open, visit: https://claude.com/cai/oauth/authorize?code=true\");",
374
+ "setTimeout(() => {",
375
+ " process.stdout.write('&state=abc\\nPaste code here if prompted > ');",
376
+ "}, 1000);",
377
+ ].join(""));
146
378
 
147
379
  function readStartupInput(html: string): string {
148
380
  const match = /const startupInput = (.*);/.exec(html);
@@ -173,10 +405,10 @@ function isMessage(
173
405
  return Boolean(value && typeof value === "object" && (value as { type?: unknown }).type === type);
174
406
  }
175
407
 
176
- async function waitFor(assertion: () => boolean): Promise<void> {
408
+ async function waitFor(assertion: () => boolean, timeoutMs = 1_000): Promise<void> {
177
409
  const started = Date.now();
178
410
  while (!assertion()) {
179
- if (Date.now() - started > 1_000) throw new Error("Timed out waiting for terminal event");
411
+ if (Date.now() - started > timeoutMs) throw new Error("Timed out waiting for terminal event");
180
412
  await new Promise((resolve) => setTimeout(resolve, 10));
181
413
  }
182
414
  }