@sweny-ai/core 0.1.65 → 0.1.67

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 @@
1
+ export {};
@@ -0,0 +1,201 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { Command } from "commander";
6
+ import { runWorkflowDiagram } from "../diagram.js";
7
+ const sampleWorkflow = {
8
+ id: "sample",
9
+ name: "Sample",
10
+ description: "sample",
11
+ entry: "a",
12
+ nodes: {
13
+ a: { name: "A", instruction: "do a", skills: [] },
14
+ b: { name: "B", instruction: "do b", skills: [] },
15
+ },
16
+ edges: [{ from: "a", to: "b" }],
17
+ };
18
+ // Minimal in-memory writable stream that accumulates text. Avoids the
19
+ // overhead of real Node streams for these pure-output assertions.
20
+ function captureStream() {
21
+ const chunks = [];
22
+ const stream = {
23
+ write: (chunk) => {
24
+ chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
25
+ return true;
26
+ },
27
+ };
28
+ return {
29
+ stream,
30
+ get text() {
31
+ return chunks.join("");
32
+ },
33
+ };
34
+ }
35
+ describe("runWorkflowDiagram", () => {
36
+ let tmpdir;
37
+ const loadWorkflowFile = vi.fn((_f) => sampleWorkflow);
38
+ const exit = vi.fn();
39
+ beforeEach(() => {
40
+ tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), "sweny-diagram-test-"));
41
+ loadWorkflowFile.mockClear();
42
+ exit.mockClear();
43
+ });
44
+ afterEach(() => {
45
+ fs.rmSync(tmpdir, { recursive: true, force: true });
46
+ });
47
+ describe("stdout", () => {
48
+ it("writes a fenced mermaid block to stdout by default", () => {
49
+ const stdout = captureStream();
50
+ const stderr = captureStream();
51
+ runWorkflowDiagram("any.yml", {}, { loadWorkflowFile, stdout: stdout.stream, stderr: stderr.stream, exit });
52
+ expect(stdout.text).toMatch(/^```mermaid\n/);
53
+ expect(stdout.text).toMatch(/\n```\n$/);
54
+ expect(exit).not.toHaveBeenCalled();
55
+ });
56
+ it("writes raw mermaid when --no-block is explicit", () => {
57
+ const stdout = captureStream();
58
+ const stderr = captureStream();
59
+ runWorkflowDiagram("any.yml", { block: false }, { loadWorkflowFile, stdout: stdout.stream, stderr: stderr.stream, exit });
60
+ expect(stdout.text).not.toMatch(/```mermaid/);
61
+ expect(stdout.text).toMatch(/graph TB/);
62
+ });
63
+ it("honors --direction LR", () => {
64
+ const stdout = captureStream();
65
+ runWorkflowDiagram("any.yml", { direction: "LR" }, { loadWorkflowFile, stdout: stdout.stream, stderr: captureStream().stream, exit });
66
+ expect(stdout.text).toMatch(/graph LR/);
67
+ });
68
+ it("honors --title override", () => {
69
+ const stdout = captureStream();
70
+ runWorkflowDiagram("any.yml", { title: "Custom Title" }, { loadWorkflowFile, stdout: stdout.stream, stderr: captureStream().stream, exit });
71
+ expect(stdout.text).toContain("Custom Title");
72
+ });
73
+ });
74
+ describe("-o / --output file", () => {
75
+ it("writes raw mermaid to .mmd by default (no fence)", () => {
76
+ const out = path.join(tmpdir, "diagram.mmd");
77
+ const stderr = captureStream();
78
+ runWorkflowDiagram("any.yml", { output: out }, { loadWorkflowFile, stdout: captureStream().stream, stderr: stderr.stream, exit });
79
+ const contents = fs.readFileSync(out, "utf8");
80
+ expect(contents).not.toMatch(/```mermaid/);
81
+ expect(contents).toMatch(/graph TB/);
82
+ expect(stderr.text).toMatch(/✓ Wrote diagram to/);
83
+ });
84
+ it("writes fenced markdown to .md by default", () => {
85
+ const out = path.join(tmpdir, "diagram.md");
86
+ runWorkflowDiagram("any.yml", { output: out }, { loadWorkflowFile, stdout: captureStream().stream, stderr: captureStream().stream, exit });
87
+ const contents = fs.readFileSync(out, "utf8");
88
+ expect(contents).toMatch(/^```mermaid\n/);
89
+ expect(contents).toMatch(/\n```\n$/);
90
+ });
91
+ it("falls back to fenced default for unknown extensions", () => {
92
+ const out = path.join(tmpdir, "diagram.txt");
93
+ runWorkflowDiagram("any.yml", { output: out }, { loadWorkflowFile, stdout: captureStream().stream, stderr: captureStream().stream, exit });
94
+ const contents = fs.readFileSync(out, "utf8");
95
+ expect(contents).toMatch(/```mermaid/);
96
+ });
97
+ it("creates parent directories as needed (-o nested/subdir/file.mmd)", () => {
98
+ const out = path.join(tmpdir, "a", "b", "c", "diagram.mmd");
99
+ runWorkflowDiagram("any.yml", { output: out }, { loadWorkflowFile, stdout: captureStream().stream, stderr: captureStream().stream, exit });
100
+ expect(fs.existsSync(out)).toBe(true);
101
+ expect(fs.readFileSync(out, "utf8")).toMatch(/graph TB/);
102
+ });
103
+ it("does not write anything to stdout when -o is set", () => {
104
+ const stdout = captureStream();
105
+ const out = path.join(tmpdir, "diagram.mmd");
106
+ runWorkflowDiagram("any.yml", { output: out }, { loadWorkflowFile, stdout: stdout.stream, stderr: captureStream().stream, exit });
107
+ expect(stdout.text).toBe("");
108
+ });
109
+ });
110
+ describe("explicit --block / --no-block precedence over extension inference", () => {
111
+ function cmdWithBlock(block, explicit) {
112
+ const cmd = new Command();
113
+ cmd.option("--block", "fenced", true);
114
+ cmd.option("--no-block", "raw");
115
+ if (explicit) {
116
+ // Simulate user typing `--block` or `--no-block` on the CLI — commander
117
+ // marks the source as "cli" when the flag is seen in argv.
118
+ cmd.setOptionValueWithSource("block", block, "cli");
119
+ }
120
+ return cmd;
121
+ }
122
+ it("explicit --block wins over .mmd extension (writes fenced to .mmd)", () => {
123
+ const out = path.join(tmpdir, "diagram.mmd");
124
+ runWorkflowDiagram("any.yml", { output: out, block: true }, {
125
+ loadWorkflowFile,
126
+ command: cmdWithBlock(true, true),
127
+ stdout: captureStream().stream,
128
+ stderr: captureStream().stream,
129
+ exit,
130
+ });
131
+ expect(fs.readFileSync(out, "utf8")).toMatch(/```mermaid/);
132
+ });
133
+ it("explicit --no-block wins over .md extension (writes raw to .md)", () => {
134
+ const out = path.join(tmpdir, "diagram.md");
135
+ runWorkflowDiagram("any.yml", { output: out, block: false }, {
136
+ loadWorkflowFile,
137
+ command: cmdWithBlock(false, true),
138
+ stdout: captureStream().stream,
139
+ stderr: captureStream().stream,
140
+ exit,
141
+ });
142
+ expect(fs.readFileSync(out, "utf8")).not.toMatch(/```mermaid/);
143
+ });
144
+ it("default --block source does not suppress .mmd extension inference", () => {
145
+ // Commander always sets `block: true` via the declared default, but its
146
+ // source is "default" — our inference should still kick in for .mmd.
147
+ const out = path.join(tmpdir, "diagram.mmd");
148
+ runWorkflowDiagram("any.yml", { output: out, block: true }, {
149
+ loadWorkflowFile,
150
+ command: cmdWithBlock(true, false), // not explicit
151
+ stdout: captureStream().stream,
152
+ stderr: captureStream().stream,
153
+ exit,
154
+ });
155
+ expect(fs.readFileSync(out, "utf8")).not.toMatch(/```mermaid/);
156
+ });
157
+ });
158
+ describe("builtin workflow names", () => {
159
+ it("resolves 'triage' without calling loadWorkflowFile", () => {
160
+ const stdout = captureStream();
161
+ runWorkflowDiagram("triage", {}, { loadWorkflowFile, stdout: stdout.stream, stderr: captureStream().stream, exit });
162
+ expect(loadWorkflowFile).not.toHaveBeenCalled();
163
+ expect(stdout.text).toMatch(/```mermaid/);
164
+ });
165
+ it("resolves 'implement' without calling loadWorkflowFile", () => {
166
+ const stdout = captureStream();
167
+ runWorkflowDiagram("implement", {}, { loadWorkflowFile, stdout: stdout.stream, stderr: captureStream().stream, exit });
168
+ expect(loadWorkflowFile).not.toHaveBeenCalled();
169
+ expect(stdout.text.length).toBeGreaterThan(0);
170
+ });
171
+ it("resolves 'seed-content' without calling loadWorkflowFile", () => {
172
+ const stdout = captureStream();
173
+ runWorkflowDiagram("seed-content", {}, { loadWorkflowFile, stdout: stdout.stream, stderr: captureStream().stream, exit });
174
+ expect(loadWorkflowFile).not.toHaveBeenCalled();
175
+ expect(stdout.text.length).toBeGreaterThan(0);
176
+ });
177
+ });
178
+ describe("error handling", () => {
179
+ it("exits with code 1 and prints an error when loadWorkflowFile throws", () => {
180
+ const stderr = captureStream();
181
+ const throwingLoader = vi.fn(() => {
182
+ throw new Error("file not found: missing.yml");
183
+ });
184
+ runWorkflowDiagram("missing.yml", {}, { loadWorkflowFile: throwingLoader, stdout: captureStream().stream, stderr: stderr.stream, exit });
185
+ expect(exit).toHaveBeenCalledWith(1);
186
+ expect(stderr.text).toMatch(/file not found/);
187
+ });
188
+ it("exits with code 1 when the output path is unwritable", () => {
189
+ // Point -o at a path where the parent is a file (not a dir). mkdirSync
190
+ // with recursive:true will throw EEXIST, writeFileSync then fails. We
191
+ // specifically want to assert the error path doesn't crash the process.
192
+ const blocker = path.join(tmpdir, "blocker");
193
+ fs.writeFileSync(blocker, "im-a-file");
194
+ const out = path.join(blocker, "diagram.mmd");
195
+ const stderr = captureStream();
196
+ runWorkflowDiagram("any.yml", { output: out }, { loadWorkflowFile, stdout: captureStream().stream, stderr: stderr.stream, exit });
197
+ expect(exit).toHaveBeenCalledWith(1);
198
+ expect(stderr.text).toMatch(/Error:/);
199
+ });
200
+ });
201
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,306 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { compareVersions, detectPackageManager, resolveInstallRoot, installCommandFor, runUpgrade, fetchLatestFromNpm, } from "../upgrade.js";
3
+ // In-memory writable stream for pure assertions on output. Cheaper and
4
+ // simpler than a real Node stream and identical to the helper used by
5
+ // diagram.test.ts — keeps test utilities consistent across the cli/ dir.
6
+ function captureStream() {
7
+ const chunks = [];
8
+ const stream = {
9
+ write: (chunk) => {
10
+ chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
11
+ return true;
12
+ },
13
+ };
14
+ return {
15
+ stream,
16
+ get text() {
17
+ return chunks.join("");
18
+ },
19
+ };
20
+ }
21
+ /**
22
+ * Build an UpgradeDeps object with sensible defaults. Tests override only
23
+ * the bits relevant to the behavior they're asserting — keeps each case
24
+ * focused on its condition rather than plumbing.
25
+ */
26
+ function makeDeps(over = {}) {
27
+ const stdout = captureStream();
28
+ const stderr = captureStream();
29
+ const exit = vi.fn();
30
+ const runInstall = vi.fn(() => 0);
31
+ const deps = {
32
+ currentVersion: "0.1.65",
33
+ installPath: "/Users/x/.nvm/versions/node/v22/lib/node_modules/@sweny-ai/core/dist/cli/main.js",
34
+ fetchLatestVersion: vi.fn(async () => "0.1.66"),
35
+ runInstall,
36
+ canWrite: () => true,
37
+ stdout: stdout.stream,
38
+ stderr: stderr.stream,
39
+ exit,
40
+ ...over,
41
+ };
42
+ return { deps, stdout, stderr, exit, runInstall };
43
+ }
44
+ describe("compareVersions", () => {
45
+ it("ranks by numeric core segments", () => {
46
+ expect(compareVersions("0.1.10", "0.1.9")).toBeGreaterThan(0);
47
+ expect(compareVersions("0.1.9", "0.1.10")).toBeLessThan(0);
48
+ expect(compareVersions("1.0.0", "0.9.999")).toBeGreaterThan(0);
49
+ expect(compareVersions("0.1.65", "0.1.65")).toBe(0);
50
+ });
51
+ it("handles missing segments as zero", () => {
52
+ expect(compareVersions("1", "1.0.0")).toBe(0);
53
+ expect(compareVersions("1.0", "1.0.1")).toBeLessThan(0);
54
+ });
55
+ it("ranks pre-releases below their release", () => {
56
+ expect(compareVersions("1.0.0-alpha.1", "1.0.0")).toBeLessThan(0);
57
+ expect(compareVersions("1.0.0", "1.0.0-beta.3")).toBeGreaterThan(0);
58
+ expect(compareVersions("1.0.0-alpha", "1.0.0-beta")).toBeLessThan(0);
59
+ });
60
+ it("treats non-numeric core segments as zero (defensive)", () => {
61
+ // Malformed numeric segments shouldn't throw or return NaN — they
62
+ // collapse to 0 so the comparison still returns a deterministic ordering.
63
+ expect(compareVersions("1.x.0", "1.0.0")).toBe(0);
64
+ expect(compareVersions("1.0.y", "1.0.0")).toBe(0);
65
+ });
66
+ });
67
+ describe("detectPackageManager", () => {
68
+ it("detects npm from nvm-style layout", () => {
69
+ expect(detectPackageManager("/Users/x/.nvm/versions/node/v22.22.0/lib/node_modules/@sweny-ai/core/dist/cli/main.js")).toBe("npm");
70
+ });
71
+ it("detects npm from fnm / asdf / vanilla Node paths", () => {
72
+ expect(detectPackageManager("/usr/lib/node_modules/@sweny-ai/core/dist/cli/main.js")).toBe("npm");
73
+ expect(detectPackageManager("/home/x/.fnm/node-versions/v22/installation/lib/node_modules/@sweny-ai/core/dist/cli/main.js")).toBe("npm");
74
+ });
75
+ it("detects pnpm global installs", () => {
76
+ expect(detectPackageManager("/Users/x/.local/share/pnpm/global/5/node_modules/@sweny-ai/core/dist/cli/main.js")).toBe("pnpm");
77
+ expect(detectPackageManager("/Users/x/Library/pnpm-global/5/node_modules/@sweny-ai/core/dist/cli/main.js")).toBe("pnpm");
78
+ });
79
+ it("detects homebrew", () => {
80
+ expect(detectPackageManager("/opt/homebrew/Cellar/sweny/0.1.65/libexec/bin/sweny")).toBe("brew");
81
+ expect(detectPackageManager("/usr/local/Cellar/sweny/0.1.65/libexec/bin/sweny")).toBe("brew");
82
+ });
83
+ it("detects volta-managed installs", () => {
84
+ expect(detectPackageManager("/Users/x/.volta/tools/image/packages/@sweny-ai/core/bin/sweny")).toBe("volta");
85
+ });
86
+ it("detects bun global installs", () => {
87
+ expect(detectPackageManager("/Users/x/.bun/install/global/node_modules/@sweny-ai/core/dist/cli/main.js")).toBe("bun");
88
+ });
89
+ it("detects yarn global", () => {
90
+ expect(detectPackageManager("/Users/x/.yarn/global/node_modules/@sweny-ai/core/dist/cli/main.js")).toBe("yarn");
91
+ });
92
+ it("normalizes Windows paths (forward-slash rules)", () => {
93
+ const win = "C:\\Users\\x\\AppData\\Roaming\\npm\\node_modules\\@sweny-ai\\core\\dist\\cli\\main.js";
94
+ expect(detectPackageManager(win)).toBe("npm");
95
+ });
96
+ it("returns 'unknown' for paths that don't look like a package-manager install", () => {
97
+ expect(detectPackageManager("/tmp/some-random-path/main.js")).toBe("unknown");
98
+ });
99
+ });
100
+ describe("resolveInstallRoot", () => {
101
+ it("returns the prefix above node_modules for a typical npm layout", () => {
102
+ const p = "/Users/x/.nvm/versions/node/v22/lib/node_modules/@sweny-ai/core/dist/cli/main.js";
103
+ expect(resolveInstallRoot(p)).toBe("/Users/x/.nvm/versions/node/v22/lib");
104
+ });
105
+ it("returns null when @sweny-ai is not in the parent chain", () => {
106
+ expect(resolveInstallRoot("/tmp/random/main.js")).toBeNull();
107
+ });
108
+ });
109
+ describe("installCommandFor", () => {
110
+ it("emits the correct CLI invocation per package manager", () => {
111
+ expect(installCommandFor("npm", "latest")).toEqual({
112
+ cmd: "npm",
113
+ args: ["install", "-g", "@sweny-ai/core@latest"],
114
+ });
115
+ expect(installCommandFor("pnpm", "latest")).toEqual({ cmd: "pnpm", args: ["add", "-g", "@sweny-ai/core@latest"] });
116
+ expect(installCommandFor("yarn", "latest")).toEqual({
117
+ cmd: "yarn",
118
+ args: ["global", "add", "@sweny-ai/core@latest"],
119
+ });
120
+ expect(installCommandFor("bun", "latest")).toEqual({ cmd: "bun", args: ["add", "-g", "@sweny-ai/core@latest"] });
121
+ expect(installCommandFor("volta", "latest")).toEqual({ cmd: "volta", args: ["install", "@sweny-ai/core@latest"] });
122
+ });
123
+ it("passes through non-default tags", () => {
124
+ expect(installCommandFor("npm", "beta")).toEqual({ cmd: "npm", args: ["install", "-g", "@sweny-ai/core@beta"] });
125
+ });
126
+ it("returns null for brew / unknown (can't drive automatically)", () => {
127
+ expect(installCommandFor("brew", "latest")).toBeNull();
128
+ expect(installCommandFor("unknown", "latest")).toBeNull();
129
+ });
130
+ });
131
+ describe("runUpgrade — no-op paths", () => {
132
+ it("reports already-latest and exits 0", async () => {
133
+ const { deps, stdout, exit, runInstall } = makeDeps({
134
+ currentVersion: "0.1.66",
135
+ fetchLatestVersion: vi.fn(async () => "0.1.66"),
136
+ });
137
+ await runUpgrade({}, deps);
138
+ expect(stdout.text).toMatch(/already the latest/);
139
+ expect(runInstall).not.toHaveBeenCalled();
140
+ expect(exit).toHaveBeenCalledWith(0);
141
+ });
142
+ it("reports when running ahead of the registry (local build) and exits 0", async () => {
143
+ const { deps, stdout, exit, runInstall } = makeDeps({
144
+ currentVersion: "0.2.0",
145
+ fetchLatestVersion: vi.fn(async () => "0.1.66"),
146
+ });
147
+ await runUpgrade({}, deps);
148
+ expect(stdout.text).toMatch(/newer than the published/);
149
+ expect(runInstall).not.toHaveBeenCalled();
150
+ expect(exit).toHaveBeenCalledWith(0);
151
+ });
152
+ it("installs anyway when --force is set even if already latest", async () => {
153
+ const { deps, runInstall, exit } = makeDeps({
154
+ currentVersion: "0.1.66",
155
+ fetchLatestVersion: vi.fn(async () => "0.1.66"),
156
+ });
157
+ await runUpgrade({ force: true }, deps);
158
+ expect(runInstall).toHaveBeenCalledWith("npm", ["install", "-g", "@sweny-ai/core@latest"]);
159
+ expect(exit).toHaveBeenCalledWith(0);
160
+ });
161
+ });
162
+ describe("runUpgrade — install dispatch", () => {
163
+ it("invokes the detected package manager and exits 0 on success", async () => {
164
+ const { deps, stdout, runInstall, exit } = makeDeps();
165
+ await runUpgrade({}, deps);
166
+ expect(runInstall).toHaveBeenCalledWith("npm", ["install", "-g", "@sweny-ai/core@latest"]);
167
+ expect(stdout.text).toMatch(/Upgrading sweny 0\.1\.65 → 0\.1\.66/);
168
+ expect(stdout.text).toMatch(/✓ Upgraded sweny to 0\.1\.66/);
169
+ expect(exit).toHaveBeenCalledWith(0);
170
+ });
171
+ it("uses pnpm when the install path is a pnpm global root", async () => {
172
+ const { deps, runInstall } = makeDeps({
173
+ installPath: "/Users/x/.local/share/pnpm/global/5/node_modules/@sweny-ai/core/dist/cli/main.js",
174
+ });
175
+ await runUpgrade({}, deps);
176
+ expect(runInstall).toHaveBeenCalledWith("pnpm", ["add", "-g", "@sweny-ai/core@latest"]);
177
+ });
178
+ it("forwards a non-default --tag to the install command", async () => {
179
+ const { deps, runInstall } = makeDeps({
180
+ fetchLatestVersion: vi.fn(async (tag) => (tag === "beta" ? "0.2.0-beta.1" : "0.1.66")),
181
+ });
182
+ await runUpgrade({ tag: "beta" }, deps);
183
+ expect(runInstall).toHaveBeenCalledWith("npm", ["install", "-g", "@sweny-ai/core@beta"]);
184
+ });
185
+ it("propagates a non-zero install exit code", async () => {
186
+ const { deps, stderr, exit } = makeDeps({
187
+ runInstall: vi.fn(() => 42),
188
+ });
189
+ await runUpgrade({}, deps);
190
+ expect(stderr.text).toMatch(/exited with code 42/);
191
+ expect(exit).toHaveBeenCalledWith(42);
192
+ });
193
+ });
194
+ describe("runUpgrade — permissions + platform edges", () => {
195
+ it("prints sudo hint and exits 1 when the install prefix isn't writable", async () => {
196
+ const { deps, stderr, exit, runInstall } = makeDeps({
197
+ canWrite: () => false,
198
+ });
199
+ await runUpgrade({}, deps);
200
+ expect(stderr.text).toMatch(/no write permission/);
201
+ expect(stderr.text).toMatch(/sudo npm install -g @sweny-ai\/core@latest/);
202
+ expect(runInstall).not.toHaveBeenCalled();
203
+ expect(exit).toHaveBeenCalledWith(1);
204
+ });
205
+ it("defers to brew for homebrew installs (doesn't try to npm-install over it)", async () => {
206
+ const { deps, stdout, stderr, exit, runInstall } = makeDeps({
207
+ installPath: "/opt/homebrew/Cellar/sweny/0.1.65/libexec/bin/sweny",
208
+ });
209
+ await runUpgrade({}, deps);
210
+ expect(stdout.text).toMatch(/0\.1\.66 is available/);
211
+ expect(stderr.text).toMatch(/brew upgrade sweny/);
212
+ expect(runInstall).not.toHaveBeenCalled();
213
+ expect(exit).toHaveBeenCalledWith(1);
214
+ });
215
+ it("prints a manual-install hint for unknown layouts", async () => {
216
+ const { deps, stdout, stderr, exit, runInstall } = makeDeps({
217
+ installPath: "/tmp/some-random-path/main.js",
218
+ });
219
+ await runUpgrade({}, deps);
220
+ expect(stdout.text).toMatch(/0\.1\.66 is available/);
221
+ expect(stderr.text).toMatch(/npm install -g @sweny-ai\/core@latest/);
222
+ expect(runInstall).not.toHaveBeenCalled();
223
+ expect(exit).toHaveBeenCalledWith(1);
224
+ });
225
+ });
226
+ describe("runUpgrade — --check (dry run)", () => {
227
+ it("reports the command without running it", async () => {
228
+ const { deps, stdout, runInstall, exit } = makeDeps();
229
+ await runUpgrade({ check: true }, deps);
230
+ expect(stdout.text).toMatch(/0\.1\.66 is available/);
231
+ expect(stdout.text).toMatch(/npm install -g @sweny-ai\/core@latest/);
232
+ expect(runInstall).not.toHaveBeenCalled();
233
+ expect(exit).toHaveBeenCalledWith(0);
234
+ });
235
+ it("still short-circuits on already-latest under --check", async () => {
236
+ const { deps, stdout, exit } = makeDeps({
237
+ currentVersion: "0.1.66",
238
+ fetchLatestVersion: vi.fn(async () => "0.1.66"),
239
+ });
240
+ await runUpgrade({ check: true }, deps);
241
+ expect(stdout.text).toMatch(/already the latest/);
242
+ expect(exit).toHaveBeenCalledWith(0);
243
+ });
244
+ });
245
+ describe("runUpgrade — error handling", () => {
246
+ it("exits 1 with a helpful message when the registry fetch fails", async () => {
247
+ const { deps, stderr, exit, runInstall } = makeDeps({
248
+ fetchLatestVersion: vi.fn(async () => {
249
+ throw new Error("ENETDOWN");
250
+ }),
251
+ });
252
+ await runUpgrade({}, deps);
253
+ expect(stderr.text).toMatch(/couldn't reach the npm registry/);
254
+ expect(stderr.text).toMatch(/ENETDOWN/);
255
+ expect(runInstall).not.toHaveBeenCalled();
256
+ expect(exit).toHaveBeenCalledWith(1);
257
+ });
258
+ });
259
+ describe("fetchLatestFromNpm", () => {
260
+ it("parses the dist-tag JSON and returns the version", async () => {
261
+ const fetchSpy = vi
262
+ .spyOn(globalThis, "fetch")
263
+ .mockImplementation(async () => new Response(JSON.stringify({ version: "9.9.9" }), { status: 200 }));
264
+ try {
265
+ expect(await fetchLatestFromNpm("latest")).toBe("9.9.9");
266
+ expect(fetchSpy).toHaveBeenCalledWith("https://registry.npmjs.org/@sweny-ai/core/latest", expect.objectContaining({ headers: expect.objectContaining({ accept: "application/json" }) }));
267
+ }
268
+ finally {
269
+ fetchSpy.mockRestore();
270
+ }
271
+ });
272
+ it("throws a clear error when the registry returns non-2xx", async () => {
273
+ const fetchSpy = vi
274
+ .spyOn(globalThis, "fetch")
275
+ .mockImplementation(async () => new Response("not found", { status: 404, statusText: "Not Found" }));
276
+ try {
277
+ await expect(fetchLatestFromNpm("bogus-tag")).rejects.toThrow(/404/);
278
+ }
279
+ finally {
280
+ fetchSpy.mockRestore();
281
+ }
282
+ });
283
+ it("throws when the registry payload is missing `version`", async () => {
284
+ const fetchSpy = vi
285
+ .spyOn(globalThis, "fetch")
286
+ .mockImplementation(async () => new Response(JSON.stringify({}), { status: 200 }));
287
+ try {
288
+ await expect(fetchLatestFromNpm("latest")).rejects.toThrow(/version/);
289
+ }
290
+ finally {
291
+ fetchSpy.mockRestore();
292
+ }
293
+ });
294
+ it("URL-encodes the tag so custom tags don't break the path", async () => {
295
+ const fetchSpy = vi
296
+ .spyOn(globalThis, "fetch")
297
+ .mockImplementation(async () => new Response(JSON.stringify({ version: "1.0.0" }), { status: 200 }));
298
+ try {
299
+ await fetchLatestFromNpm("next beta");
300
+ expect(fetchSpy).toHaveBeenCalledWith("https://registry.npmjs.org/@sweny-ai/core/next%20beta", expect.anything());
301
+ }
302
+ finally {
303
+ fetchSpy.mockRestore();
304
+ }
305
+ });
306
+ });
@@ -0,0 +1 @@
1
+ export {};