open-ready 0.2.14 → 0.2.16

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.
@@ -310,6 +310,8 @@ export {
310
310
  extractPortlessUrl,
311
311
  extractUrl,
312
312
  getErrorContext,
313
+ resolvePortless,
314
+ run,
313
315
  };
314
316
 
315
317
  function isMain() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-ready",
3
- "version": "0.2.14",
3
+ "version": "0.2.16",
4
4
  "description": "Smart dev server launcher: error→AI search, success→auto-open browser. Any CLI tool.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,282 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ /**
7
+ * Drives the real `run()` loop: a real child process prints to the log, the
8
+ * poller reads it back, and `opener` / `wait-on` are mocked so nothing actually
9
+ * opens a browser. Each case imports a fresh module because the CLI parses
10
+ * `process.argv` once, at load time.
11
+ */
12
+ const opener = vi.hoisted(() => vi.fn());
13
+ const waitOn = vi.hoisted(() => vi.fn());
14
+ vi.mock("opener", () => ({ default: opener }));
15
+ vi.mock("wait-on", () => ({ default: waitOn }));
16
+
17
+ let cwd;
18
+ let tmp;
19
+ let origArgv;
20
+ let sigintBefore;
21
+ let origTTY;
22
+ let origCI;
23
+
24
+ const printfArgs = (text) => ["printf", `'${text}'`];
25
+
26
+ async function load(args) {
27
+ vi.resetModules();
28
+ process.argv = ["node", "open-when-ready.mjs", ...args];
29
+ return import("../open-when-ready.mjs");
30
+ }
31
+
32
+ async function until(check, ms = 3000) {
33
+ const start = Date.now();
34
+ while (Date.now() - start < ms) {
35
+ if (check()) return true;
36
+ await new Promise((r) => setTimeout(r, 15));
37
+ }
38
+ return check();
39
+ }
40
+
41
+ beforeEach(() => {
42
+ cwd = process.cwd();
43
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "owr-run-"));
44
+ process.chdir(tmp);
45
+ origArgv = process.argv;
46
+ sigintBefore = process.listeners("SIGINT");
47
+ origTTY = process.stdin.isTTY;
48
+ origCI = process.env.CI;
49
+ opener.mockReset();
50
+ waitOn.mockReset().mockResolvedValue(undefined);
51
+ vi.spyOn(console, "error").mockImplementation(() => {});
52
+ vi.spyOn(process.stdout, "write").mockImplementation(() => true);
53
+ vi.spyOn(process.stderr, "write").mockImplementation(() => true);
54
+ });
55
+
56
+ afterEach(() => {
57
+ process.chdir(cwd);
58
+ process.argv = origArgv;
59
+ for (const l of process.listeners("SIGINT")) {
60
+ if (!sigintBefore.includes(l)) process.off("SIGINT", l);
61
+ }
62
+ process.stdin.isTTY = origTTY;
63
+ if (origCI === undefined) delete process.env.CI;
64
+ else process.env.CI = origCI;
65
+ vi.restoreAllMocks();
66
+ fs.rmSync(tmp, { recursive: true, force: true });
67
+ });
68
+
69
+ describe("run() — readiness", () => {
70
+ it("opens the local URL once the server reports ready", async () => {
71
+ const mod = await load([
72
+ ...printfArgs(" Local: http://localhost:4123\\n ✓ Ready in 50ms\\n"),
73
+ "--pollDelay=20",
74
+ "--noAi",
75
+ "--no-portless",
76
+ ]);
77
+ await mod.run();
78
+ expect(await until(() => opener.mock.calls.length > 0)).toBe(true);
79
+ expect(opener).toHaveBeenCalledWith("http://localhost:4123");
80
+ expect(waitOn).toHaveBeenCalledWith("http://localhost:4123", { timeout: 10000, http: true });
81
+ expect(fs.readFileSync("open-when-ready.log", "utf8")).toContain("Ready in 50ms");
82
+ });
83
+
84
+ it("opens anyway when waiting on the port fails", async () => {
85
+ waitOn.mockRejectedValue(new Error("timeout"));
86
+ const mod = await load([
87
+ ...printfArgs("Local: http://localhost:4200\\nReady in 5ms\\n"),
88
+ "--pollDelay=20",
89
+ "--noAi",
90
+ "--no-portless",
91
+ ]);
92
+ await mod.run();
93
+ expect(await until(() => opener.mock.calls.length > 0)).toBe(true);
94
+ expect(opener).toHaveBeenCalledWith("http://localhost:4200");
95
+ });
96
+
97
+ it("does not open anything with --noOpen", async () => {
98
+ const mod = await load([
99
+ ...printfArgs("Local: http://localhost:4300\\nReady in 5ms\\n"),
100
+ "--pollDelay=20",
101
+ "--noAi",
102
+ "--noOpen",
103
+ "--no-portless",
104
+ ]);
105
+ await mod.run();
106
+ await new Promise((r) => setTimeout(r, 300));
107
+ expect(opener).not.toHaveBeenCalled();
108
+ expect(waitOn).not.toHaveBeenCalled();
109
+ });
110
+
111
+ it("uses .next/port.log when a .next directory exists", async () => {
112
+ fs.mkdirSync(".next");
113
+ const mod = await load([
114
+ ...printfArgs("Local: http://localhost:4400\\nReady in 5ms\\n"),
115
+ "--pollDelay=20",
116
+ "--noAi",
117
+ "--no-portless",
118
+ ]);
119
+ await mod.run();
120
+ expect(await until(() => opener.mock.calls.length > 0)).toBe(true);
121
+ expect(fs.existsSync(path.join(".next", "port.log"))).toBe(true);
122
+ });
123
+ });
124
+
125
+ describe("run() — failures", () => {
126
+ it("opens an AI prompt about the error", async () => {
127
+ const mod = await load([
128
+ ...printfArgs("compiling...\\nError: Cannot find module foo\\n"),
129
+ "--pollDelay=20",
130
+ "--ai-base=https://ai.example/?q=",
131
+ "--no-portless",
132
+ ]);
133
+ await mod.run();
134
+ expect(await until(() => opener.mock.calls.length > 0)).toBe(true);
135
+ const url = opener.mock.calls[0][0];
136
+ expect(url.startsWith("https://ai.example/?q=")).toBe(true);
137
+ expect(decodeURIComponent(url)).toContain("Explain what the error is");
138
+ expect(url).toContain("next.js+");
139
+ expect(url).toContain("Cannot%20find%20module%20foo");
140
+ // Only once, however many polls see the error.
141
+ await new Promise((r) => setTimeout(r, 150));
142
+ expect(opener).toHaveBeenCalledTimes(1);
143
+ });
144
+
145
+ it("stays quiet about errors with --noAi", async () => {
146
+ const mod = await load([
147
+ ...printfArgs("Error: boom\\n"),
148
+ "--pollDelay=20",
149
+ "--noAi",
150
+ "--no-portless",
151
+ ]);
152
+ await mod.run();
153
+ await new Promise((r) => setTimeout(r, 200));
154
+ expect(opener).not.toHaveBeenCalled();
155
+ });
156
+
157
+ it("stops polling once the log has stopped changing", async () => {
158
+ const mod = await load([
159
+ ...printfArgs("x".repeat(200)),
160
+ "--pollDelay=15",
161
+ "--noAi",
162
+ "--no-portless",
163
+ ]);
164
+ const spy = vi.spyOn(globalThis, "clearInterval");
165
+ await mod.run();
166
+ expect(await until(() => spy.mock.calls.length > 0, 2000)).toBe(true);
167
+ });
168
+
169
+ it("clears the poll and exits on SIGINT", async () => {
170
+ const exit = vi.spyOn(process, "exit").mockImplementation(() => undefined);
171
+ const mod = await load([...printfArgs("hello\\n"), "--pollDelay=500", "--noAi", "--no-portless"]);
172
+ await mod.run();
173
+ const handler = process.listeners("SIGINT").find((l) => !sigintBefore.includes(l));
174
+ handler();
175
+ expect(exit).toHaveBeenCalledWith(0);
176
+ });
177
+ });
178
+
179
+ describe("portless integration", () => {
180
+ /** A stub `portless`: `proxy start` succeeds/fails; `run --name N cmd…` runs cmd. */
181
+ function installPortlessStub({ proxyOk = true } = {}) {
182
+ const bin = path.join(tmp, "node_modules", ".bin");
183
+ fs.mkdirSync(bin, { recursive: true });
184
+ const stub = path.join(bin, "portless");
185
+ fs.writeFileSync(
186
+ stub,
187
+ `#!/bin/sh
188
+ if [ "$1" = "proxy" ]; then exit ${proxyOk ? 0 : 1}; fi
189
+ shift 3
190
+ exec "$@"
191
+ `,
192
+ );
193
+ fs.chmodSync(stub, 0o755);
194
+ return stub;
195
+ }
196
+
197
+ it("wraps the command and opens the portless URL when --portless is given", async () => {
198
+ installPortlessStub();
199
+ const mod = await load([
200
+ ...printfArgs("Local: http://localhost:4500\\n -> https://myapp.localhost\\nReady in 5ms\\n"),
201
+ "--pollDelay=20",
202
+ "--noAi",
203
+ "--portless",
204
+ "--name=MyApp",
205
+ ]);
206
+ await mod.run();
207
+ expect(await until(() => opener.mock.calls.length > 0)).toBe(true);
208
+ expect(opener).toHaveBeenCalledWith("https://myapp.localhost");
209
+ expect(waitOn).toHaveBeenCalledWith("http://localhost:4500", expect.any(Object));
210
+ });
211
+
212
+ it("resolvePortless reports the binary and a sanitized name", async () => {
213
+ const stub = installPortlessStub();
214
+ const mod = await load(["echo", "hi", "--portless", "--name=My App"]);
215
+ expect(mod.resolvePortless()).toEqual({ portlessBin: fs.realpathSync(stub) === stub ? stub : stub, appName: "my-app" });
216
+ });
217
+
218
+ it("warns and runs without portless when --portless is given but it is missing", async () => {
219
+ process.env.PATH = "/usr/bin:/bin";
220
+ const mod = await load(["echo", "hi", "--portless"]);
221
+ expect(mod.resolvePortless().portlessBin).toBeNull();
222
+ expect(console.error).toHaveBeenCalledWith(expect.stringContaining("--portless given but portless is not installed"));
223
+ });
224
+
225
+ it("skips portless when not interactive and not forced", async () => {
226
+ installPortlessStub();
227
+ const mod = await load(["echo", "hi"]);
228
+ expect(mod.resolvePortless().portlessBin).toBeNull();
229
+ });
230
+
231
+ it("skips portless with --no-portless even when forced by a TTY", async () => {
232
+ installPortlessStub();
233
+ process.stdin.isTTY = true;
234
+ delete process.env.CI;
235
+ const mod = await load(["echo", "hi", "--no-portless"]);
236
+ expect(mod.resolvePortless().portlessBin).toBeNull();
237
+ });
238
+
239
+ it("starts the proxy in an interactive session", async () => {
240
+ const stub = installPortlessStub({ proxyOk: true });
241
+ process.stdin.isTTY = true;
242
+ delete process.env.CI;
243
+ const mod = await load(["echo", "hi"]);
244
+ expect(mod.resolvePortless().portlessBin).toBe(stub);
245
+ });
246
+
247
+ it("falls back when the proxy cannot start", async () => {
248
+ installPortlessStub({ proxyOk: false });
249
+ process.stdin.isTTY = true;
250
+ delete process.env.CI;
251
+ const mod = await load(["echo", "hi"]);
252
+ expect(mod.resolvePortless().portlessBin).toBeNull();
253
+ expect(console.error).toHaveBeenCalledWith(expect.stringContaining("Could not start the portless proxy"));
254
+ });
255
+
256
+ it("falls back to the directory name when --name is not given", async () => {
257
+ const mod = await load(["echo", "hi"]);
258
+ expect(mod.resolvePortless().appName).toBe(path.basename(tmp).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, ""));
259
+ });
260
+ });
261
+
262
+ describe("getErrorContext", () => {
263
+ it("returns an encoded excerpt around the first error line", async () => {
264
+ const mod = await load(["echo"]);
265
+ const log = ["a", "b", "c", "SyntaxError: bad thing (x.js:1)", "d", "e"].join("\n");
266
+ const ctx = mod.getErrorContext(log);
267
+ expect(ctx).toContain("SyntaxError");
268
+ expect(ctx).toContain("%20");
269
+ expect(ctx).not.toMatch(/[()]/);
270
+ });
271
+
272
+ it("returns an empty string when nothing looks like an error", async () => {
273
+ const mod = await load(["echo"]);
274
+ expect(mod.getErrorContext("all good\nready")).toBe("");
275
+ });
276
+
277
+ it("caps the excerpt length", async () => {
278
+ const mod = await load(["echo"]);
279
+ const log = "error " + "word ".repeat(2000);
280
+ expect(mod.getErrorContext(log).length).toBeLessThanOrEqual(1000 + 2000);
281
+ });
282
+ });