import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { join } from "node:path"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; // Must import _detectDeps directly to test the dep detection logic import { _detectDeps, unwrapSkillList } from "./suggest.js"; describe("suggest", () => { let tmpDir: string; beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), "suggest-test-")); }); afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); describe("detectDeps", () => { it("reads dependencies from package.json", () => { writeFileSync( join(tmpDir, "package.json"), JSON.stringify({ dependencies: { react: "^18.0.0", next: "^15.0.0" }, devDependencies: { typescript: "^5.0.0" }, }), ); const deps = _detectDeps(tmpDir); expect(deps).toContain("react"); expect(deps).toContain("next"); expect(deps).toContain("typescript"); }); it("reads dependencies from requirements.txt", () => { writeFileSync( join(tmpDir, "requirements.txt"), "django>=4.0\nflask\nrequests==2.28.0\n# comment\n", ); const deps = _detectDeps(tmpDir); expect(deps).toContain("django"); expect(deps).toContain("flask"); expect(deps).toContain("requests"); }); it("reads gems from Gemfile", () => { writeFileSync( join(tmpDir, "Gemfile"), "source 'https://rubygems.org'\ngem 'rails'\ngem 'puma', '~> 5.0'\n", ); const deps = _detectDeps(tmpDir); expect(deps).toContain("rails"); expect(deps).toContain("puma"); }); it("reads modules from go.mod", () => { writeFileSync( join(tmpDir, "go.mod"), "module example.com/myapp\n\nrequire (\n\tgithub.com/gin-gonic/gin v1.9.0\n\tgithub.com/lib/pq v1.10.0\n)\n", ); const deps = _detectDeps(tmpDir); expect(deps).toContain("gin"); expect(deps).toContain("pq"); }); it("returns empty for directory with no dep files", () => { const deps = _detectDeps(tmpDir); expect(deps).toHaveLength(0); }); it("deduplicates dependencies", () => { writeFileSync( join(tmpDir, "package.json"), JSON.stringify({ dependencies: { react: "^18.0.0" }, devDependencies: { react: "^18.0.0" }, }), ); const deps = _detectDeps(tmpDir); expect(deps.filter((d) => d === "react")).toHaveLength(1); }); it("handles malformed package.json gracefully", () => { writeFileSync(join(tmpDir, "package.json"), "not json"); const deps = _detectDeps(tmpDir); expect(deps).toHaveLength(0); }); it("reads dependencies from Cargo.toml", () => { writeFileSync( join(tmpDir, "Cargo.toml"), '[package]\nname = "myapp"\n\n[dependencies]\nserde = "1.0"\ntokio = { version = "1", features = ["full"] }\nreqwest = "0.11"\n', ); const deps = _detectDeps(tmpDir); expect(deps).toContain("serde"); expect(deps).toContain("tokio"); expect(deps).toContain("reqwest"); }); it("reads dependencies from pubspec.yaml", () => { writeFileSync( join(tmpDir, "pubspec.yaml"), "name: myapp\n\ndependencies:\n flutter:\n sdk: flutter\n http: ^0.13.0\n provider: ^6.0.0\n\ndev_dependencies:\n flutter_test:\n sdk: flutter\n", ); const deps = _detectDeps(tmpDir); expect(deps).toContain("flutter"); expect(deps).toContain("http"); expect(deps).toContain("provider"); }); it("merges deps from multiple files without duplicates", () => { writeFileSync( join(tmpDir, "package.json"), JSON.stringify({ dependencies: { express: "^4.0.0" } }), ); writeFileSync(join(tmpDir, "requirements.txt"), "flask\nexpress\n"); const deps = _detectDeps(tmpDir); expect(deps.filter((d) => d === "express")).toHaveLength(1); expect(deps).toContain("flask"); }); it("handles malformed Cargo.toml gracefully", () => { writeFileSync(join(tmpDir, "Cargo.toml"), "[dependencies]\n"); const deps = _detectDeps(tmpDir); expect(deps).toHaveLength(0); }); }); }); describe("unwrapSkillList", () => { const skill = { slug: "a", name: "A" } as never; it("reads the {data,cursor,hasMore} envelope the API returns today", () => { expect( unwrapSkillList({ data: [skill], cursor: null, hasMore: false } as never), ).toEqual([skill]); }); it("still reads the bare array older self-hosted APIs return", () => { // Regression: POST /search/suggest-by-deps switched from a bare array to // an envelope on 2026-07-30. The CLI kept doing `for (const s of skills)` // on the response, which throws "skills is not iterable" against the new // shape. Both shapes must work, because published CLIs cannot be recalled. expect(unwrapSkillList([skill] as never)).toEqual([skill]); }); it("degrades to an empty list instead of throwing on junk", () => { for (const junk of [null, undefined, {}, { data: null }, { data: 5 }]) { expect(unwrapSkillList(junk as never)).toEqual([]); } }); });