sema-cli 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.
@@ -0,0 +1,307 @@
1
+ "use strict";
2
+
3
+ const { describe, it } = require("node:test");
4
+ const assert = require("node:assert/strict");
5
+ const { analyze, stripAnsi } = require("./analyzer");
6
+
7
+ // --- stripAnsi ---
8
+
9
+ describe("stripAnsi", () => {
10
+ it("removes color codes", () => {
11
+ assert.equal(stripAnsi("\x1b[31mRed text\x1b[0m"), "Red text");
12
+ assert.equal(stripAnsi("\x1b[1;32mBold green\x1b[0m"), "Bold green");
13
+ });
14
+
15
+ it("removes cursor movement", () => {
16
+ assert.equal(stripAnsi("\x1b[2J\x1b[H"), "");
17
+ assert.equal(stripAnsi("\x1b[10A\x1b[5B"), "");
18
+ });
19
+
20
+ it("removes private mode sequences", () => {
21
+ assert.equal(stripAnsi("\x1b[?25h\x1b[?25l"), "");
22
+ assert.equal(stripAnsi("\x1b[?1049h"), "");
23
+ });
24
+
25
+ it("removes OSC sequences", () => {
26
+ assert.equal(stripAnsi("\x1b]0;Window Title\x07"), "");
27
+ });
28
+
29
+ it("keeps newlines and tabs", () => {
30
+ assert.equal(stripAnsi("line1\nline2\tcol"), "line1\nline2\tcol");
31
+ });
32
+
33
+ it("removes carriage returns", () => {
34
+ assert.equal(stripAnsi("hello\r\nworld\r"), "hello\nworld");
35
+ });
36
+
37
+ it("removes control characters", () => {
38
+ assert.equal(stripAnsi("hello\x07world\x08!"), "helloworld!");
39
+ });
40
+
41
+ it("trims trailing whitespace", () => {
42
+ assert.equal(stripAnsi("hello \n\n"), "hello");
43
+ });
44
+
45
+ it("handles complex terminal output", () => {
46
+ const input = "\x1b[2J\x1b[H\x1b[1;36m$\x1b[0m ls\r\nfile1.txt\r\nfile2.txt\r\n";
47
+ const result = stripAnsi(input);
48
+ assert.ok(result.includes("$ ls"));
49
+ assert.ok(result.includes("file1.txt"));
50
+ assert.ok(!result.includes("\x1b"));
51
+ });
52
+ });
53
+
54
+ // --- analyze: yesno ---
55
+
56
+ describe("analyze: yesno", () => {
57
+ it("detects [y/n] prompt", () => {
58
+ const result = analyze("Overwrite file.txt? [y/n] ");
59
+ assert.ok(result);
60
+ assert.equal(result.kind, "yesno");
61
+ assert.equal(result.confidence, 0.9);
62
+ assert.ok(result.question.includes("[y/n]"));
63
+ assert.deepEqual(result.options, [
64
+ { label: "Yes", data: "y\n" },
65
+ { label: "No", data: "n\n" },
66
+ ]);
67
+ });
68
+
69
+ it("detects (y/n) variant", () => {
70
+ const result = analyze("Continue? (y/n) ");
71
+ assert.ok(result);
72
+ assert.equal(result.kind, "yesno");
73
+ });
74
+
75
+ it("detects (Y/n) variant", () => {
76
+ const result = analyze("Install packages? (Y/n) ");
77
+ assert.ok(result);
78
+ assert.equal(result.kind, "yesno");
79
+ });
80
+
81
+ it("detects (y/N) variant", () => {
82
+ const result = analyze("Delete all files? (y/N) ");
83
+ assert.ok(result);
84
+ assert.equal(result.kind, "yesno");
85
+ });
86
+
87
+ it("detects [Y/n] variant", () => {
88
+ const result = analyze("Proceed? [Y/n] ");
89
+ assert.ok(result);
90
+ assert.equal(result.kind, "yesno");
91
+ });
92
+
93
+ it("extracts question line", () => {
94
+ const result = analyze("Some context\nOverwrite src/auth.ts? [y/n] ");
95
+ assert.ok(result);
96
+ assert.equal(result.question, "Overwrite src/auth.ts? [y/n]");
97
+ assert.ok(result.context.includes("Some context"));
98
+ });
99
+
100
+ it("handles ANSI in yesno prompt", () => {
101
+ const result = analyze("\x1b[1;33mAllow edit to\x1b[0m file.ts? \x1b[36m[y/n]\x1b[0m ");
102
+ assert.ok(result);
103
+ assert.equal(result.kind, "yesno");
104
+ assert.ok(!result.question.includes("\x1b"));
105
+ });
106
+
107
+ it("does not match without y/n bracket pattern", () => {
108
+ const result = analyze("Do you want to continue? yes or no");
109
+ assert.equal(result, null);
110
+ });
111
+ });
112
+
113
+ // --- analyze: choice ---
114
+
115
+ describe("analyze: choice", () => {
116
+ it("detects numbered choices", () => {
117
+ const input = "Select a file:\n1. src/auth.ts\n2. src/config.ts\n3. src/index.ts\n";
118
+ const result = analyze(input);
119
+ assert.ok(result);
120
+ assert.equal(result.kind, "choice");
121
+ assert.equal(result.confidence, 0.8);
122
+ assert.equal(result.options.length, 4); // 3 choices + Other
123
+ assert.equal(result.options[0].label, "1. src/auth.ts");
124
+ assert.equal(result.options[0].data, "1\n");
125
+ assert.equal(result.options[2].data, "3\n");
126
+ assert.equal(result.options[3].label, "Other");
127
+ assert.equal(result.options[3].data, "");
128
+ });
129
+
130
+ it("detects choices with dot separator", () => {
131
+ const input = "Choose:\n1. Option A\n2. Option B\n";
132
+ const result = analyze(input);
133
+ assert.ok(result);
134
+ assert.equal(result.kind, "choice");
135
+ });
136
+
137
+ it("detects choices with parenthesis separator", () => {
138
+ const input = "Choose:\n1) Option A\n2) Option B\n";
139
+ const result = analyze(input);
140
+ assert.ok(result);
141
+ assert.equal(result.kind, "choice");
142
+ });
143
+
144
+ it("extracts question from line before choices", () => {
145
+ const input = "Which file to edit?\n1. auth.ts\n2. config.ts\n";
146
+ const result = analyze(input);
147
+ assert.ok(result);
148
+ assert.equal(result.question, "Which file to edit?");
149
+ });
150
+
151
+ it("requires at least 2 choice lines", () => {
152
+ const input = "Only one:\n1. option\n";
153
+ const result = analyze(input);
154
+ // Should not match as choice with only 1 option
155
+ assert.equal(result, null);
156
+ });
157
+
158
+ it("defaults question to 'Select an option' when no prompt line", () => {
159
+ const input = "1. first\n2. second\n3. third\n";
160
+ const result = analyze(input);
161
+ assert.ok(result);
162
+ assert.equal(result.question, "Select an option");
163
+ });
164
+ });
165
+
166
+ // --- analyze: error ---
167
+
168
+ describe("analyze: error", () => {
169
+ it("detects line-start Error:", () => {
170
+ const input = "Running build...\nError: Cannot find module 'foo'\nPress Enter to continue.";
171
+ const result = analyze(input);
172
+ assert.ok(result);
173
+ assert.equal(result.kind, "error");
174
+ assert.equal(result.confidence, 0.7);
175
+ assert.ok(result.question.includes("Error:"));
176
+ });
177
+
178
+ it("detects 'failed' keyword", () => {
179
+ const result = analyze("Build failed\nPress any key...");
180
+ assert.ok(result);
181
+ assert.equal(result.kind, "error");
182
+ });
183
+
184
+ it("detects permission denied", () => {
185
+ const result = analyze("ls: /root: Permission denied\nHit Enter...");
186
+ assert.ok(result);
187
+ assert.equal(result.kind, "error");
188
+ });
189
+
190
+ it("detects ENOENT", () => {
191
+ const result = analyze("ENOENT: no such file or directory\nContinue?");
192
+ assert.ok(result);
193
+ assert.equal(result.kind, "error");
194
+ });
195
+
196
+ it("detects command not found", () => {
197
+ const result = analyze("zsh: command not found: foo\nPress Enter...");
198
+ assert.ok(result);
199
+ assert.equal(result.kind, "error");
200
+ });
201
+
202
+ it("detects non-zero exit code", () => {
203
+ const result = analyze("Process exited with code 1\nPress Enter...");
204
+ assert.ok(result);
205
+ assert.equal(result.kind, "error");
206
+ });
207
+
208
+ it("detects unicode error symbols", () => {
209
+ const result = analyze("✗ Build failed\nPress Enter...");
210
+ assert.ok(result);
211
+ assert.equal(result.kind, "error");
212
+ });
213
+
214
+ it("rejects '0 errors found'", () => {
215
+ const result = analyze("Tests passed: 0 errors found\nDone.");
216
+ assert.equal(result, null);
217
+ });
218
+
219
+ it("rejects 'no error'", () => {
220
+ const result = analyze("There was no error in the build\nAll good.");
221
+ assert.equal(result, null);
222
+ });
223
+
224
+ it("rejects 'error rate: 0%'", () => {
225
+ const result = analyze("Deployment error rate: 0%\nAll systems go.");
226
+ assert.equal(result, null);
227
+ });
228
+
229
+ it("rejects when 'success' is on the same line", () => {
230
+ const result = analyze("Build success, error count: 0\nDone.");
231
+ assert.equal(result, null);
232
+ });
233
+
234
+ it("error has Acknowledge option with \\r", () => {
235
+ const result = analyze("Error: something broke\nAcknowledge to continue.");
236
+ assert.ok(result);
237
+ assert.deepEqual(result.options, [{ label: "Acknowledge", data: "\r" }]);
238
+ });
239
+ });
240
+
241
+ // --- analyze: quality gate ---
242
+
243
+ describe("analyze: quality gate", () => {
244
+ it("returns null for unrecognized idle", () => {
245
+ const result = analyze("Process running...\nWaiting for input...");
246
+ assert.equal(result, null);
247
+ });
248
+
249
+ it("returns null for empty input", () => {
250
+ assert.equal(analyze(""), null);
251
+ assert.equal(analyze(" "), null);
252
+ assert.equal(analyze(null), null);
253
+ assert.equal(analyze(undefined), null);
254
+ });
255
+
256
+ it("returns null for plain text output", () => {
257
+ const result = analyze("Hello world\nThis is a test\nNothing special here");
258
+ assert.equal(result, null);
259
+ });
260
+ });
261
+
262
+ // --- analyze: buffer handling ---
263
+
264
+ describe("analyze: buffer handling", () => {
265
+ it("handles truncated output buffer", () => {
266
+ // Simulate 4096-char buffer with prompt at the end
267
+ const padding = "x".repeat(4000);
268
+ const input = padding + "\nContinue? [y/n] ";
269
+ const result = analyze(input);
270
+ assert.ok(result);
271
+ assert.equal(result.kind, "yesno");
272
+ });
273
+
274
+ it("handles very long input", () => {
275
+ const input = "line\n".repeat(1000) + "Error: something\n";
276
+ const result = analyze(input);
277
+ assert.ok(result);
278
+ assert.equal(result.kind, "error");
279
+ });
280
+
281
+ it("strips ANSI from question text", () => {
282
+ const input = "\x1b[1m\x1b[31mError: something broke\x1b[0m\nPress Enter...";
283
+ const result = analyze(input);
284
+ assert.ok(result);
285
+ assert.ok(!result.question.includes("\x1b"));
286
+ assert.ok(result.question.includes("Error:"));
287
+ });
288
+ });
289
+
290
+ // --- analyze: priority ---
291
+
292
+ describe("analyze: priority order", () => {
293
+ it("yesno takes priority over error when both present", () => {
294
+ // Error line + y/n prompt → should match yesno
295
+ const input = "Error: file conflict\nOverwrite? [y/n] ";
296
+ const result = analyze(input);
297
+ assert.ok(result);
298
+ assert.equal(result.kind, "yesno");
299
+ });
300
+
301
+ it("choice takes priority over error when both present", () => {
302
+ const input = "Error occurred. Choose action:\n1. Retry\n2. Skip\n3. Abort\n";
303
+ const result = analyze(input);
304
+ assert.ok(result);
305
+ assert.equal(result.kind, "choice");
306
+ });
307
+ });
@@ -0,0 +1,38 @@
1
+ const http = require("node:http");
2
+
3
+ /**
4
+ * Create a new session on the relay server.
5
+ *
6
+ * @param {string} relayHttpUrl - e.g. "http://127.0.0.1:8787"
7
+ * @returns {Promise<{sessionId: string, code: string, macToken: string, pairUrl: string}>}
8
+ */
9
+ async function createSession(relayHttpUrl) {
10
+ const url = `${relayHttpUrl}/api/sessions`;
11
+
12
+ return new Promise((resolve, reject) => {
13
+ const req = http.request(url, { method: "POST" }, (res) => {
14
+ const chunks = [];
15
+ res.on("data", (chunk) => chunks.push(chunk));
16
+ res.on("end", () => {
17
+ try {
18
+ const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
19
+ if (res.statusCode >= 400) {
20
+ reject(new Error(body.error || `HTTP ${res.statusCode}`));
21
+ return;
22
+ }
23
+ resolve(body);
24
+ } catch (err) {
25
+ reject(new Error("Invalid JSON response from relay"));
26
+ }
27
+ });
28
+ });
29
+
30
+ req.on("error", (err) => {
31
+ reject(new Error(`Cannot reach relay at ${relayHttpUrl}: ${err.message}`));
32
+ });
33
+
34
+ req.end();
35
+ });
36
+ }
37
+
38
+ module.exports = { createSession };