feat(ce-polish-beta): human-in-the-loop polish phase between /ce:review and merge (#568)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { promises as fs } from "fs"
|
||||
import path from "path"
|
||||
import { loadClaudePlugin } from "../src/parsers/claude"
|
||||
import { convertClaudeToOpenCode, transformSkillContentForOpenCode } from "../src/converters/claude-to-opencode"
|
||||
@@ -6,6 +7,12 @@ import { parseFrontmatter } from "../src/utils/frontmatter"
|
||||
import type { ClaudePlugin } from "../src/types/claude"
|
||||
|
||||
const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
|
||||
const compoundEngineeringRoot = path.join(
|
||||
import.meta.dir,
|
||||
"..",
|
||||
"plugins",
|
||||
"compound-engineering",
|
||||
)
|
||||
|
||||
describe("convertClaudeToOpenCode", () => {
|
||||
test("from-command mode: map allowedTools to global permission block", async () => {
|
||||
|
||||
253
tests/skills/ce-polish-beta-dev-server.test.ts
Normal file
253
tests/skills/ce-polish-beta-dev-server.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { promises as fs } from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
|
||||
const readLaunchJson = path.join(
|
||||
import.meta.dir,
|
||||
"..",
|
||||
"..",
|
||||
"plugins",
|
||||
"compound-engineering",
|
||||
"skills",
|
||||
"ce-polish-beta",
|
||||
"scripts",
|
||||
"read-launch-json.sh",
|
||||
)
|
||||
|
||||
const detectProjectType = path.join(
|
||||
import.meta.dir,
|
||||
"..",
|
||||
"..",
|
||||
"plugins",
|
||||
"compound-engineering",
|
||||
"skills",
|
||||
"ce-polish-beta",
|
||||
"scripts",
|
||||
"detect-project-type.sh",
|
||||
)
|
||||
|
||||
const gitEnv = {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: "Test",
|
||||
GIT_AUTHOR_EMAIL: "test@example.com",
|
||||
GIT_COMMITTER_NAME: "Test",
|
||||
GIT_COMMITTER_EMAIL: "test@example.com",
|
||||
}
|
||||
|
||||
type RunResult = {
|
||||
exitCode: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
}
|
||||
|
||||
async function runCommand(cmd: string[], cwd: string): Promise<RunResult> {
|
||||
const proc = Bun.spawn(cmd, {
|
||||
cwd,
|
||||
env: gitEnv,
|
||||
stderr: "pipe",
|
||||
stdout: "pipe",
|
||||
})
|
||||
|
||||
const [exitCode, stdout, stderr] = await Promise.all([
|
||||
proc.exited,
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
])
|
||||
|
||||
return { exitCode, stdout, stderr }
|
||||
}
|
||||
|
||||
async function initRepo(): Promise<string> {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "ce-polish-devserver-"))
|
||||
await runCommand(["git", "init", "-b", "main"], root)
|
||||
return root
|
||||
}
|
||||
|
||||
async function writeJson(filePath: string, data: unknown): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
||||
await fs.writeFile(filePath, JSON.stringify(data, null, 2))
|
||||
}
|
||||
|
||||
async function touch(filePath: string, content = ""): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
||||
await fs.writeFile(filePath, content)
|
||||
}
|
||||
|
||||
describe("read-launch-json.sh", () => {
|
||||
test("emits __NO_LAUNCH_JSON__ when file is absent", async () => {
|
||||
const repo = await initRepo()
|
||||
const result = await runCommand(["bash", readLaunchJson], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("__NO_LAUNCH_JSON__")
|
||||
})
|
||||
|
||||
test("emits __INVALID_LAUNCH_JSON__ for malformed JSON", async () => {
|
||||
const repo = await initRepo()
|
||||
const launchPath = path.join(repo, ".claude", "launch.json")
|
||||
await fs.mkdir(path.dirname(launchPath), { recursive: true })
|
||||
await fs.writeFile(launchPath, "{ not valid json ")
|
||||
const result = await runCommand(["bash", readLaunchJson], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("__INVALID_LAUNCH_JSON__")
|
||||
})
|
||||
|
||||
test("emits __MISSING_CONFIGURATIONS__ when configurations array is absent", async () => {
|
||||
const repo = await initRepo()
|
||||
await writeJson(path.join(repo, ".claude", "launch.json"), { version: "0.2.0" })
|
||||
const result = await runCommand(["bash", readLaunchJson], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("__MISSING_CONFIGURATIONS__")
|
||||
})
|
||||
|
||||
test("returns the single configuration verbatim when there is exactly one", async () => {
|
||||
const repo = await initRepo()
|
||||
const config = {
|
||||
name: "Rails dev",
|
||||
runtimeExecutable: "bin/dev",
|
||||
runtimeArgs: [],
|
||||
port: 3000,
|
||||
}
|
||||
await writeJson(path.join(repo, ".claude", "launch.json"), {
|
||||
version: "0.2.0",
|
||||
configurations: [config],
|
||||
})
|
||||
|
||||
const result = await runCommand(["bash", readLaunchJson], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
|
||||
const parsed = JSON.parse(result.stdout.trim())
|
||||
expect(parsed).toEqual(config)
|
||||
})
|
||||
|
||||
test("emits __MULTIPLE_CONFIGS__ and name list when called without arg", async () => {
|
||||
const repo = await initRepo()
|
||||
await writeJson(path.join(repo, ".claude", "launch.json"), {
|
||||
version: "0.2.0",
|
||||
configurations: [
|
||||
{ name: "web", runtimeExecutable: "bin/dev", port: 3000 },
|
||||
{ name: "worker", runtimeExecutable: "bundle", runtimeArgs: ["exec", "sidekiq"], port: 0 },
|
||||
],
|
||||
})
|
||||
|
||||
const result = await runCommand(["bash", readLaunchJson], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
|
||||
const lines = result.stdout.trim().split("\n")
|
||||
expect(lines[0]).toBe("__MULTIPLE_CONFIGS__")
|
||||
expect(JSON.parse(lines[1]!)).toEqual(["web", "worker"])
|
||||
})
|
||||
|
||||
test("returns the named configuration when called with an arg", async () => {
|
||||
const repo = await initRepo()
|
||||
const web = { name: "web", runtimeExecutable: "bin/dev", port: 3000 }
|
||||
const worker = { name: "worker", runtimeExecutable: "bundle", port: 0 }
|
||||
await writeJson(path.join(repo, ".claude", "launch.json"), {
|
||||
version: "0.2.0",
|
||||
configurations: [web, worker],
|
||||
})
|
||||
|
||||
const result = await runCommand(["bash", readLaunchJson, "worker"], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(JSON.parse(result.stdout.trim())).toEqual(worker)
|
||||
})
|
||||
|
||||
test("emits __CONFIG_NOT_FOUND__ when the named config does not exist in a multi-config file", async () => {
|
||||
const repo = await initRepo()
|
||||
await writeJson(path.join(repo, ".claude", "launch.json"), {
|
||||
version: "0.2.0",
|
||||
configurations: [
|
||||
{ name: "web", runtimeExecutable: "bin/dev", port: 3000 },
|
||||
{ name: "worker", runtimeExecutable: "bundle", port: 0 },
|
||||
],
|
||||
})
|
||||
|
||||
const result = await runCommand(["bash", readLaunchJson, "missing"], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("__CONFIG_NOT_FOUND__")
|
||||
})
|
||||
})
|
||||
|
||||
describe("detect-project-type.sh", () => {
|
||||
test("returns 'rails' when bin/dev + Gemfile are present", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "bin", "dev"), "#!/usr/bin/env bash\n")
|
||||
await touch(path.join(repo, "Gemfile"), "source 'https://rubygems.org'\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("rails")
|
||||
})
|
||||
|
||||
test("returns 'next' when next.config.mjs is present", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "next.config.mjs"), "export default {}\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("next")
|
||||
})
|
||||
|
||||
test("returns 'next' for next.config.ts", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "next.config.ts"), "export default {}\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.stdout.trim()).toBe("next")
|
||||
})
|
||||
|
||||
test("returns 'vite' when vite.config.ts is present", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "vite.config.ts"), "export default {}\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("vite")
|
||||
})
|
||||
|
||||
test("returns 'procfile' when Procfile.dev is present without bin/dev", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "Procfile.dev"), "web: node server.js\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("procfile")
|
||||
})
|
||||
|
||||
test("Rails wins over bare Procfile (common Rails layout has both)", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "bin", "dev"), "#!/usr/bin/env bash\n")
|
||||
await touch(path.join(repo, "Gemfile"), "source 'x'\n")
|
||||
await touch(path.join(repo, "Procfile.dev"), "web: bin/rails s\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.stdout.trim()).toBe("rails")
|
||||
})
|
||||
|
||||
test("returns 'multiple' when Rails and Next both match", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "bin", "dev"), "#!/usr/bin/env bash\n")
|
||||
await touch(path.join(repo, "Gemfile"), "source 'x'\n")
|
||||
await touch(path.join(repo, "next.config.mjs"), "export default {}\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.stdout.trim()).toBe("multiple")
|
||||
})
|
||||
|
||||
test("returns 'multiple' for Next + Vite together", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "next.config.mjs"), "export default {}\n")
|
||||
await touch(path.join(repo, "vite.config.ts"), "export default {}\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.stdout.trim()).toBe("multiple")
|
||||
})
|
||||
|
||||
test("returns 'unknown' when no signatures match", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "README.md"), "# nothing\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("unknown")
|
||||
})
|
||||
|
||||
test("returns 'unknown' when only a Gemfile is present (no bin/dev)", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "Gemfile"), "source 'x'\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
// Gemfile alone is not a Rails signature -- tons of gems have Gemfiles.
|
||||
expect(result.stdout.trim()).toBe("unknown")
|
||||
})
|
||||
})
|
||||
201
tests/skills/ce-polish-beta-package-manager.test.ts
Normal file
201
tests/skills/ce-polish-beta-package-manager.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { promises as fs } from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
|
||||
const resolvePackageManager = path.join(
|
||||
import.meta.dir,
|
||||
"..",
|
||||
"..",
|
||||
"plugins",
|
||||
"compound-engineering",
|
||||
"skills",
|
||||
"ce-polish-beta",
|
||||
"scripts",
|
||||
"resolve-package-manager.sh",
|
||||
)
|
||||
|
||||
const gitEnv = {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: "Test",
|
||||
GIT_AUTHOR_EMAIL: "test@example.com",
|
||||
GIT_COMMITTER_NAME: "Test",
|
||||
GIT_COMMITTER_EMAIL: "test@example.com",
|
||||
}
|
||||
|
||||
type RunResult = {
|
||||
exitCode: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
}
|
||||
|
||||
async function runCommand(cmd: string[], cwd: string): Promise<RunResult> {
|
||||
const proc = Bun.spawn(cmd, {
|
||||
cwd,
|
||||
env: gitEnv,
|
||||
stderr: "pipe",
|
||||
stdout: "pipe",
|
||||
})
|
||||
|
||||
const [exitCode, stdout, stderr] = await Promise.all([
|
||||
proc.exited,
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
])
|
||||
|
||||
return { exitCode, stdout, stderr }
|
||||
}
|
||||
|
||||
async function initRepo(): Promise<string> {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "ce-polish-pkgmgr-"))
|
||||
await runCommand(["git", "init", "-b", "main"], root)
|
||||
return root
|
||||
}
|
||||
|
||||
async function touch(filePath: string, content = ""): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
||||
await fs.writeFile(filePath, content)
|
||||
}
|
||||
|
||||
async function writeJson(filePath: string, data: unknown): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
||||
await fs.writeFile(filePath, JSON.stringify(data, null, 2))
|
||||
}
|
||||
|
||||
describe("resolve-package-manager.sh", () => {
|
||||
// --- Happy paths ---
|
||||
|
||||
test("pnpm-lock.yaml present -> pnpm / dev", async () => {
|
||||
const repo = await initRepo()
|
||||
await writeJson(path.join(repo, "package.json"), { name: "test" })
|
||||
await touch(path.join(repo, "pnpm-lock.yaml"))
|
||||
const result = await runCommand(["bash", resolvePackageManager], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
const lines = result.stdout.trim().split("\n")
|
||||
expect(lines[0]).toBe("pnpm")
|
||||
expect(lines[1]).toBe("dev")
|
||||
})
|
||||
|
||||
test("yarn.lock present -> yarn / dev", async () => {
|
||||
const repo = await initRepo()
|
||||
await writeJson(path.join(repo, "package.json"), { name: "test" })
|
||||
await touch(path.join(repo, "yarn.lock"))
|
||||
const result = await runCommand(["bash", resolvePackageManager], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
const lines = result.stdout.trim().split("\n")
|
||||
expect(lines[0]).toBe("yarn")
|
||||
expect(lines[1]).toBe("dev")
|
||||
})
|
||||
|
||||
test("bun.lockb present -> bun / run dev", async () => {
|
||||
const repo = await initRepo()
|
||||
await writeJson(path.join(repo, "package.json"), { name: "test" })
|
||||
await touch(path.join(repo, "bun.lockb"))
|
||||
const result = await runCommand(["bash", resolvePackageManager], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
const lines = result.stdout.trim().split("\n")
|
||||
expect(lines[0]).toBe("bun")
|
||||
expect(lines[1]).toBe("run dev")
|
||||
})
|
||||
|
||||
test("bun.lock (text format) present -> bun / run dev", async () => {
|
||||
const repo = await initRepo()
|
||||
await writeJson(path.join(repo, "package.json"), { name: "test" })
|
||||
await touch(path.join(repo, "bun.lock"))
|
||||
const result = await runCommand(["bash", resolvePackageManager], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
const lines = result.stdout.trim().split("\n")
|
||||
expect(lines[0]).toBe("bun")
|
||||
expect(lines[1]).toBe("run dev")
|
||||
})
|
||||
|
||||
test("package-lock.json present -> npm / run dev", async () => {
|
||||
const repo = await initRepo()
|
||||
await writeJson(path.join(repo, "package.json"), { name: "test" })
|
||||
await touch(path.join(repo, "package-lock.json"))
|
||||
const result = await runCommand(["bash", resolvePackageManager], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
const lines = result.stdout.trim().split("\n")
|
||||
expect(lines[0]).toBe("npm")
|
||||
expect(lines[1]).toBe("run dev")
|
||||
})
|
||||
|
||||
test("no lockfile but package.json present -> npm / run dev (safe default)", async () => {
|
||||
const repo = await initRepo()
|
||||
await writeJson(path.join(repo, "package.json"), { name: "test" })
|
||||
const result = await runCommand(["bash", resolvePackageManager], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
const lines = result.stdout.trim().split("\n")
|
||||
expect(lines[0]).toBe("npm")
|
||||
expect(lines[1]).toBe("run dev")
|
||||
})
|
||||
|
||||
// --- Priority / edge cases ---
|
||||
|
||||
test("both pnpm-lock.yaml and yarn.lock present -> pnpm wins (priority order)", async () => {
|
||||
const repo = await initRepo()
|
||||
await writeJson(path.join(repo, "package.json"), { name: "test" })
|
||||
await touch(path.join(repo, "pnpm-lock.yaml"))
|
||||
await touch(path.join(repo, "yarn.lock"))
|
||||
const result = await runCommand(["bash", resolvePackageManager], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
const lines = result.stdout.trim().split("\n")
|
||||
expect(lines[0]).toBe("pnpm")
|
||||
expect(lines[1]).toBe("dev")
|
||||
})
|
||||
|
||||
test("both bun.lockb and bun.lock present -> bun.lock wins (text preferred over binary)", async () => {
|
||||
const repo = await initRepo()
|
||||
await writeJson(path.join(repo, "package.json"), { name: "test" })
|
||||
await touch(path.join(repo, "bun.lockb"))
|
||||
await touch(path.join(repo, "bun.lock"))
|
||||
// bun.lock (text) is checked before bun.lockb (binary) in priority order,
|
||||
// so the result is the same either way -- but both present should still resolve to bun.
|
||||
const result = await runCommand(["bash", resolvePackageManager], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
const lines = result.stdout.trim().split("\n")
|
||||
expect(lines[0]).toBe("bun")
|
||||
expect(lines[1]).toBe("run dev")
|
||||
})
|
||||
|
||||
test("positional path arg pointing to subdir (apps/web) -> reads lockfile from that subdir", async () => {
|
||||
const repo = await initRepo()
|
||||
const webDir = path.join(repo, "apps", "web")
|
||||
await writeJson(path.join(webDir, "package.json"), { name: "web" })
|
||||
await touch(path.join(webDir, "yarn.lock"))
|
||||
const result = await runCommand(["bash", resolvePackageManager, webDir], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
const lines = result.stdout.trim().split("\n")
|
||||
expect(lines[0]).toBe("yarn")
|
||||
expect(lines[1]).toBe("dev")
|
||||
})
|
||||
|
||||
// --- Sentinel cases ---
|
||||
|
||||
test("directory without package.json -> __NO_PACKAGE_JSON__, exit 0", async () => {
|
||||
const repo = await initRepo()
|
||||
const result = await runCommand(["bash", resolvePackageManager], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("__NO_PACKAGE_JSON__")
|
||||
})
|
||||
|
||||
// --- Error cases ---
|
||||
|
||||
test("not in a git repo AND no positional arg -> stderr contains ERROR:, exit 1", async () => {
|
||||
// Create a plain directory (not a git repo)
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "ce-polish-pkgmgr-nogit-"))
|
||||
const result = await runCommand(["bash", resolvePackageManager], dir)
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stderr).toContain("ERROR:")
|
||||
})
|
||||
|
||||
test("positional path doesn't exist -> stderr contains ERROR:, exit 1", async () => {
|
||||
const repo = await initRepo()
|
||||
const result = await runCommand(
|
||||
["bash", resolvePackageManager, path.join(repo, "nonexistent")],
|
||||
repo,
|
||||
)
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stderr).toContain("ERROR:")
|
||||
})
|
||||
})
|
||||
340
tests/skills/ce-polish-beta-project-type.test.ts
Normal file
340
tests/skills/ce-polish-beta-project-type.test.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { promises as fs } from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
|
||||
const detectProjectType = path.join(
|
||||
import.meta.dir,
|
||||
"..",
|
||||
"..",
|
||||
"plugins",
|
||||
"compound-engineering",
|
||||
"skills",
|
||||
"ce-polish-beta",
|
||||
"scripts",
|
||||
"detect-project-type.sh",
|
||||
)
|
||||
|
||||
const gitEnv = {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: "Test",
|
||||
GIT_AUTHOR_EMAIL: "test@example.com",
|
||||
GIT_COMMITTER_NAME: "Test",
|
||||
GIT_COMMITTER_EMAIL: "test@example.com",
|
||||
}
|
||||
|
||||
type RunResult = {
|
||||
exitCode: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
}
|
||||
|
||||
async function runCommand(cmd: string[], cwd: string): Promise<RunResult> {
|
||||
const proc = Bun.spawn(cmd, {
|
||||
cwd,
|
||||
env: gitEnv,
|
||||
stderr: "pipe",
|
||||
stdout: "pipe",
|
||||
})
|
||||
|
||||
const [exitCode, stdout, stderr] = await Promise.all([
|
||||
proc.exited,
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
])
|
||||
|
||||
return { exitCode, stdout, stderr }
|
||||
}
|
||||
|
||||
async function initRepo(): Promise<string> {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "ce-polish-projtype-"))
|
||||
await runCommand(["git", "init", "-b", "main"], root)
|
||||
return root
|
||||
}
|
||||
|
||||
async function touch(filePath: string, content = ""): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
||||
await fs.writeFile(filePath, content)
|
||||
}
|
||||
|
||||
// ── New framework root detection ────────────────────────────────────────────
|
||||
|
||||
describe("detect-project-type.sh — new signatures", () => {
|
||||
test("nuxt.config.ts at root -> 'nuxt'", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "nuxt.config.ts"), "export default {}\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("nuxt")
|
||||
})
|
||||
|
||||
test("nuxt.config.mjs at root -> 'nuxt'", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "nuxt.config.mjs"), "export default {}\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("nuxt")
|
||||
})
|
||||
|
||||
test("astro.config.mjs at root -> 'astro'", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "astro.config.mjs"), "export default {}\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("astro")
|
||||
})
|
||||
|
||||
test("astro.config.ts at root -> 'astro'", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "astro.config.ts"), "export default {}\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("astro")
|
||||
})
|
||||
|
||||
test("remix.config.js at root -> 'remix'", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "remix.config.js"), "module.exports = {}\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("remix")
|
||||
})
|
||||
|
||||
test("remix.config.ts at root -> 'remix'", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "remix.config.ts"), "export default {}\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("remix")
|
||||
})
|
||||
|
||||
test("svelte.config.js at root -> 'sveltekit'", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "svelte.config.js"), "export default {}\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("sveltekit")
|
||||
})
|
||||
|
||||
test("svelte.config.mjs at root -> 'sveltekit'", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "svelte.config.mjs"), "export default {}\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("sveltekit")
|
||||
})
|
||||
})
|
||||
|
||||
// ── Monorepo probe ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("detect-project-type.sh — monorepo probe", () => {
|
||||
// Single hit in monorepo
|
||||
test("apps/web/next.config.js (no root signature) -> 'next@apps/web'", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "apps", "web", "next.config.js"), "module.exports = {}\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("next@apps/web")
|
||||
})
|
||||
|
||||
test("packages/frontend/vite.config.ts (no root signature) -> 'vite@packages/frontend'", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "packages", "frontend", "vite.config.ts"), "export default {}\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("vite@packages/frontend")
|
||||
})
|
||||
|
||||
test("apps/site/nuxt.config.ts (no root signature) -> 'nuxt@apps/site'", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "apps", "site", "nuxt.config.ts"), "export default {}\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("nuxt@apps/site")
|
||||
})
|
||||
|
||||
test("apps/docs/astro.config.mjs (no root signature) -> 'astro@apps/docs'", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "apps", "docs", "astro.config.mjs"), "export default {}\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("astro@apps/docs")
|
||||
})
|
||||
|
||||
// Multiple hits in monorepo
|
||||
test("multiple next apps in monorepo -> starts with 'multiple:' and contains both", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "apps", "web", "next.config.js"), "module.exports = {}\n")
|
||||
await touch(path.join(repo, "apps", "admin", "next.config.js"), "module.exports = {}\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
const output = result.stdout.trim()
|
||||
expect(output.startsWith("multiple:")).toBe(true)
|
||||
expect(output).toContain("next@apps/web")
|
||||
expect(output).toContain("next@apps/admin")
|
||||
})
|
||||
|
||||
test("next + rails in monorepo -> starts with 'multiple:' and contains both types", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "apps", "web", "next.config.js"), "module.exports = {}\n")
|
||||
await touch(path.join(repo, "apps", "api", "Gemfile"), "source 'x'\n")
|
||||
await touch(path.join(repo, "apps", "api", "bin", "dev"), "#!/usr/bin/env bash\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
const output = result.stdout.trim()
|
||||
expect(output.startsWith("multiple:")).toBe(true)
|
||||
expect(output).toContain("next@apps/web")
|
||||
expect(output).toContain("rails@apps/api")
|
||||
})
|
||||
|
||||
// Exclusion list
|
||||
test("node_modules/next/examples/next.config.js (no root signature) -> 'unknown'", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "node_modules", "next", "examples", "next.config.js"), "module.exports = {}\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("unknown")
|
||||
})
|
||||
|
||||
test("fixtures/sample/next.config.js (no root signature) -> 'unknown'", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "fixtures", "sample", "next.config.js"), "module.exports = {}\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("unknown")
|
||||
})
|
||||
|
||||
// Depth cap
|
||||
test("depth 4 is too deep -> 'unknown'", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(
|
||||
path.join(repo, "projects", "app", "web", "client", "next.config.js"),
|
||||
"module.exports = {}\n",
|
||||
)
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("unknown")
|
||||
})
|
||||
|
||||
test("depth 2 (apps/web) is within limit -> detected", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "apps", "web", "next.config.js"), "module.exports = {}\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("next@apps/web")
|
||||
})
|
||||
|
||||
test("depth 3 (services/api/server) is exactly at limit -> detected", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(
|
||||
path.join(repo, "services", "api", "server", "vite.config.ts"),
|
||||
"export default {}\n",
|
||||
)
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("vite@services/api/server")
|
||||
})
|
||||
|
||||
// Root wins over monorepo probe
|
||||
test("rails at root + next inside apps/web -> 'rails' (root wins)", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "bin", "dev"), "#!/usr/bin/env bash\n")
|
||||
await touch(path.join(repo, "Gemfile"), "source 'x'\n")
|
||||
await touch(path.join(repo, "apps", "web", "next.config.js"), "module.exports = {}\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("rails")
|
||||
})
|
||||
|
||||
test("next at root + vite inside packages/ui -> 'next' (root wins)", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "next.config.js"), "module.exports = {}\n")
|
||||
await touch(path.join(repo, "packages", "ui", "vite.config.ts"), "export default {}\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("next")
|
||||
})
|
||||
|
||||
// Still unknown
|
||||
test("only README.md, no signatures anywhere -> 'unknown'", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "README.md"), "# nothing\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("unknown")
|
||||
})
|
||||
|
||||
// Monorepo probe at depth 1
|
||||
test("apps/web/ with next.config.js directly in it -> 'next@apps/web'", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "apps", "web", "next.config.js"), "module.exports = {}\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("next@apps/web")
|
||||
})
|
||||
})
|
||||
|
||||
// ── Regressions ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("detect-project-type.sh — regressions", () => {
|
||||
test("bin/dev + Gemfile at root -> 'rails'", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "bin", "dev"), "#!/usr/bin/env bash\n")
|
||||
await touch(path.join(repo, "Gemfile"), "source 'https://rubygems.org'\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("rails")
|
||||
})
|
||||
|
||||
test("next.config.mjs at root -> 'next'", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "next.config.mjs"), "export default {}\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("next")
|
||||
})
|
||||
|
||||
test("vite.config.ts at root -> 'vite'", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "vite.config.ts"), "export default {}\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("vite")
|
||||
})
|
||||
|
||||
test("Procfile.dev without bin/dev -> 'procfile'", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "Procfile.dev"), "web: node server.js\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("procfile")
|
||||
})
|
||||
|
||||
test("Rails (bin/dev+Gemfile) + Procfile.dev -> 'rails' (rails wins, not multiple)", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "bin", "dev"), "#!/usr/bin/env bash\n")
|
||||
await touch(path.join(repo, "Gemfile"), "source 'x'\n")
|
||||
await touch(path.join(repo, "Procfile.dev"), "web: bin/rails s\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("rails")
|
||||
})
|
||||
|
||||
test("Rails + Next at root -> 'multiple'", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "bin", "dev"), "#!/usr/bin/env bash\n")
|
||||
await touch(path.join(repo, "Gemfile"), "source 'x'\n")
|
||||
await touch(path.join(repo, "next.config.mjs"), "export default {}\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("multiple")
|
||||
})
|
||||
|
||||
test("No signatures -> 'unknown'", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "README.md"), "# nothing\n")
|
||||
const result = await runCommand(["bash", detectProjectType], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("unknown")
|
||||
})
|
||||
})
|
||||
355
tests/skills/ce-polish-beta-resolve-port.test.ts
Normal file
355
tests/skills/ce-polish-beta-resolve-port.test.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { promises as fs } from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
|
||||
const resolvePort = path.join(
|
||||
import.meta.dir,
|
||||
"..",
|
||||
"..",
|
||||
"plugins",
|
||||
"compound-engineering",
|
||||
"skills",
|
||||
"ce-polish-beta",
|
||||
"scripts",
|
||||
"resolve-port.sh",
|
||||
)
|
||||
|
||||
const gitEnv = {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: "Test",
|
||||
GIT_AUTHOR_EMAIL: "test@example.com",
|
||||
GIT_COMMITTER_NAME: "Test",
|
||||
GIT_COMMITTER_EMAIL: "test@example.com",
|
||||
}
|
||||
|
||||
type RunResult = {
|
||||
exitCode: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
}
|
||||
|
||||
async function runCommand(cmd: string[], cwd: string): Promise<RunResult> {
|
||||
const proc = Bun.spawn(cmd, {
|
||||
cwd,
|
||||
env: gitEnv,
|
||||
stderr: "pipe",
|
||||
stdout: "pipe",
|
||||
})
|
||||
|
||||
const [exitCode, stdout, stderr] = await Promise.all([
|
||||
proc.exited,
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
])
|
||||
|
||||
return { exitCode, stdout, stderr }
|
||||
}
|
||||
|
||||
async function initRepo(): Promise<string> {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "ce-polish-resolve-port-"))
|
||||
await runCommand(["git", "init", "-b", "main"], root)
|
||||
return root
|
||||
}
|
||||
|
||||
async function writeJson(filePath: string, data: unknown): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
||||
await fs.writeFile(filePath, JSON.stringify(data, null, 2))
|
||||
}
|
||||
|
||||
async function touch(filePath: string, content = ""): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
||||
await fs.writeFile(filePath, content)
|
||||
}
|
||||
|
||||
describe("resolve-port.sh", () => {
|
||||
// Explicit override
|
||||
test("--port 8080 returns 8080", async () => {
|
||||
const repo = await initRepo()
|
||||
const result = await runCommand(["bash", resolvePort, repo, "--port", "8080"], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("8080")
|
||||
})
|
||||
|
||||
// Framework config probes
|
||||
test("next.config.js with port: 4000 returns 4000", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "next.config.js"), `module.exports = { server: { port: 4000 } }`)
|
||||
const result = await runCommand(["bash", resolvePort, repo], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("4000")
|
||||
})
|
||||
|
||||
test("next.config.ts with server: { port: 4000 } returns 4000", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(
|
||||
path.join(repo, "next.config.ts"),
|
||||
`export default { server: { port: 4000 } }`,
|
||||
)
|
||||
const result = await runCommand(["bash", resolvePort, repo], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("4000")
|
||||
})
|
||||
|
||||
test("vite.config.ts with server: { port: 8888 } returns 8888", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(
|
||||
path.join(repo, "vite.config.ts"),
|
||||
`export default { server: { port: 8888 } }`,
|
||||
)
|
||||
const result = await runCommand(["bash", resolvePort, repo], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("8888")
|
||||
})
|
||||
|
||||
// Rails
|
||||
test("config/puma.rb with port 3001 returns 3001 (with --type rails)", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "config", "puma.rb"), `port 3001\n`)
|
||||
const result = await runCommand(["bash", resolvePort, repo, "--type", "rails"], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("3001")
|
||||
})
|
||||
|
||||
test("multiline next.config.js with port on its own line returns port", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(
|
||||
path.join(repo, "next.config.js"),
|
||||
["module.exports = {", " server: {", " port: 3000", " }", "}"].join("\n"),
|
||||
)
|
||||
const result = await runCommand(["bash", resolvePort, repo], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("3000")
|
||||
})
|
||||
|
||||
// Procfile
|
||||
test("Procfile.dev web line with -p 4567 returns 4567", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "Procfile.dev"), "web: bundle exec puma -p 4567\n")
|
||||
const result = await runCommand(["bash", resolvePort, repo], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("4567")
|
||||
})
|
||||
|
||||
test("Procfile.dev web line with compact -p3000 returns 3000", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "Procfile.dev"), "web: rails s -p3000\n")
|
||||
const result = await runCommand(["bash", resolvePort, repo], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("3000")
|
||||
})
|
||||
|
||||
// docker-compose
|
||||
test('docker-compose.yml with ports: ["9000:9000"] returns 9000', async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(
|
||||
path.join(repo, "docker-compose.yml"),
|
||||
[
|
||||
"version: '3'",
|
||||
"services:",
|
||||
" web:",
|
||||
" image: myapp",
|
||||
" ports:",
|
||||
' - "9000:9000"',
|
||||
].join("\n") + "\n",
|
||||
)
|
||||
const result = await runCommand(["bash", resolvePort, repo], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("9000")
|
||||
})
|
||||
|
||||
// package.json
|
||||
test("package.json dev script with --port 4000 returns 4000", async () => {
|
||||
const repo = await initRepo()
|
||||
await writeJson(path.join(repo, "package.json"), {
|
||||
scripts: {
|
||||
dev: "next dev --port 4000",
|
||||
},
|
||||
})
|
||||
const result = await runCommand(["bash", resolvePort, repo], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("4000")
|
||||
})
|
||||
|
||||
// .env parsing
|
||||
test(".env PORT=3001 returns 3001", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, ".env"), "PORT=3001\n")
|
||||
const result = await runCommand(["bash", resolvePort, repo], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("3001")
|
||||
})
|
||||
|
||||
test('.env PORT="3001" returns 3001 (quotes stripped)', async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, ".env"), 'PORT="3001"\n')
|
||||
const result = await runCommand(["bash", resolvePort, repo], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("3001")
|
||||
})
|
||||
|
||||
test(".env PORT='3001' returns 3001 (single quotes stripped)", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, ".env"), "PORT='3001'\n")
|
||||
const result = await runCommand(["bash", resolvePort, repo], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("3001")
|
||||
})
|
||||
|
||||
test(".env PORT=3001 # dev only returns 3001 (comment stripped)", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, ".env"), "PORT=3001 # dev only\n")
|
||||
const result = await runCommand(["bash", resolvePort, repo], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("3001")
|
||||
})
|
||||
|
||||
test('.env PORT="3001" # quoted+commented returns 3001', async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, ".env"), 'PORT="3001" # quoted and commented\n')
|
||||
const result = await runCommand(["bash", resolvePort, repo], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("3001")
|
||||
})
|
||||
|
||||
// .env override order
|
||||
test(".env.local PORT=4000 + .env PORT=3000 -> .env.local wins", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, ".env.local"), "PORT=4000\n")
|
||||
await touch(path.join(repo, ".env"), "PORT=3000\n")
|
||||
const result = await runCommand(["bash", resolvePort, repo], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("4000")
|
||||
})
|
||||
|
||||
test(".env.development PORT=4000 + .env PORT=3000 -> .env.development wins", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, ".env.development"), "PORT=4000\n")
|
||||
await touch(path.join(repo, ".env"), "PORT=3000\n")
|
||||
const result = await runCommand(["bash", resolvePort, repo], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("4000")
|
||||
})
|
||||
|
||||
test(".env.local PORT=4000 + .env.development PORT=5000 -> .env.local wins", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, ".env.local"), "PORT=4000\n")
|
||||
await touch(path.join(repo, ".env.development"), "PORT=5000\n")
|
||||
const result = await runCommand(["bash", resolvePort, repo], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("4000")
|
||||
})
|
||||
|
||||
// Priority: framework config beats .env
|
||||
test("next.config.js port: 3000 + .env.local PORT=4000 -> framework config wins", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "next.config.js"), `module.exports = { server: { port: 3000 } }`)
|
||||
await touch(path.join(repo, ".env.local"), "PORT=4000\n")
|
||||
const result = await runCommand(["bash", resolvePort, repo], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("3000")
|
||||
})
|
||||
|
||||
test("multiple probes hit -- framework config wins over .env", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(
|
||||
path.join(repo, "vite.config.ts"),
|
||||
`export default { server: { port: 7777 } }`,
|
||||
)
|
||||
await touch(path.join(repo, ".env"), "PORT=9999\n")
|
||||
const result = await runCommand(["bash", resolvePort, repo], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("7777")
|
||||
})
|
||||
|
||||
// Defaults
|
||||
test("no probe matches, --type next -> 3000", async () => {
|
||||
const repo = await initRepo()
|
||||
const result = await runCommand(["bash", resolvePort, repo, "--type", "next"], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("3000")
|
||||
})
|
||||
|
||||
test("no probe matches, --type vite -> 5173", async () => {
|
||||
const repo = await initRepo()
|
||||
const result = await runCommand(["bash", resolvePort, repo, "--type", "vite"], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("5173")
|
||||
})
|
||||
|
||||
test("no probe matches, --type astro -> 4321", async () => {
|
||||
const repo = await initRepo()
|
||||
const result = await runCommand(["bash", resolvePort, repo, "--type", "astro"], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("4321")
|
||||
})
|
||||
|
||||
test("no probe matches, --type sveltekit -> 5173", async () => {
|
||||
const repo = await initRepo()
|
||||
const result = await runCommand(["bash", resolvePort, repo, "--type", "sveltekit"], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("5173")
|
||||
})
|
||||
|
||||
test("no probe matches, no --type -> 3000", async () => {
|
||||
const repo = await initRepo()
|
||||
const result = await runCommand(["bash", resolvePort, repo], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("3000")
|
||||
})
|
||||
|
||||
// Error / fallthrough
|
||||
test("malformed docker-compose.yml -> probe misses, falls through", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(path.join(repo, "docker-compose.yml"), "this is not yaml at all\n")
|
||||
const result = await runCommand(["bash", resolvePort, repo], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("3000")
|
||||
})
|
||||
|
||||
test("next.config.js with computed port: getPort() -> regex misses, falls through to default", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(
|
||||
path.join(repo, "next.config.js"),
|
||||
`module.exports = { server: { port: getPort() } }`,
|
||||
)
|
||||
const result = await runCommand(["bash", resolvePort, repo], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("3000")
|
||||
})
|
||||
|
||||
test('next.config.js with "port: process.env.PORT || 3000" -> probe rejects, falls through', async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(
|
||||
path.join(repo, "next.config.js"),
|
||||
`module.exports = { server: { port: process.env.PORT || 3000 } }`,
|
||||
)
|
||||
const result = await runCommand(["bash", resolvePort, repo], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
// The regex should NOT match "port: process.env.PORT || 3000" because it
|
||||
// contains non-numeric content. Falls through to default.
|
||||
expect(result.stdout.trim()).toBe("3000")
|
||||
})
|
||||
|
||||
test("positional path doesn't exist -> stderr ERROR: + exit 1", async () => {
|
||||
const repo = await initRepo()
|
||||
const result = await runCommand(
|
||||
["bash", resolvePort, path.join(repo, "nonexistent")],
|
||||
repo,
|
||||
)
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stderr).toContain("ERROR:")
|
||||
})
|
||||
|
||||
// Regression: AGENTS.md/CLAUDE.md NOT scanned
|
||||
test("AGENTS.md mentioning port 8443 -> ignored (returns default 3000)", async () => {
|
||||
const repo = await initRepo()
|
||||
await touch(
|
||||
path.join(repo, "AGENTS.md"),
|
||||
"# Instructions\n\nThe dev server runs on port 8443.\n",
|
||||
)
|
||||
const result = await runCommand(["bash", resolvePort, repo], repo)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("3000")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user