Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/cli/config-io.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,36 @@ describe("generateJsConfig", () => {
// Should not have any route entries between the braces
expect(output).toMatch(/routes: \{\n\s*\}/);
});

it("omits worktreeConfig block when not provided", () => {
const output = generateJsConfig({ api: "http://localhost:4000" });
expect(output).not.toContain("worktreeConfig");
});

it("emits worktreeConfig block when provided", () => {
const output = generateJsConfig(
{ api: "http://localhost:4000" },
{ portRange: [4001, 5000], directory: "../app-{branch}" },
);
expect(output).toContain("worktreeConfig:");
expect(output).toContain('"portRange"');
expect(output).toContain('"directory": "../app-{branch}"');
});

it("emits worktreeConfig with services and hooks", () => {
const output = generateJsConfig(
{},
{
portRange: [4001, 5000],
directory: "../{branch}",
services: { web: { env: "PORT" }, api: { env: "API_PORT" } },
hooks: { "post-create": "pnpm install" },
},
);
expect(output).toContain('"services"');
expect(output).toContain('"env": "PORT"');
expect(output).toContain('"post-create": "pnpm install"');
});
});

// ── writeJsConfig ───────────────────────────────────────────
Expand Down
26 changes: 22 additions & 4 deletions src/cli/config-io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,23 +179,41 @@ export function writeProjectConfig(projectPath: string, cfg: RawProjectConfig):

// ── JS config generation ────────────────────────────────────

export function generateJsConfig(routes: Record<string, string>): string {
function indentJson(value: unknown, indent: string): string {
return JSON.stringify(value, null, 2)
.split("\n")
.map((line, i) => (i === 0 ? line : indent + line))
.join("\n");
}

export function generateJsConfig(
routes: Record<string, string>,
worktreeConfig?: WorktreeConfig,
): string {
const entries = Object.entries(routes)
.map(([sub, target]) => ` ${JSON.stringify(sub)}: ${JSON.stringify(target)},`)
.join("\n");

const worktreeBlock = worktreeConfig
? `\n worktreeConfig: ${indentJson(worktreeConfig, " ")},`
: "";

return `/** @type {import('@reopt-ai/dev-proxy').Config} */
export default {
routes: {
${entries}
},
},${worktreeBlock}
};
`;
}

export function writeJsConfig(projectPath: string, routes: Record<string, string>): void {
export function writeJsConfig(
projectPath: string,
routes: Record<string, string>,
worktreeConfig?: WorktreeConfig,
): void {
const configPath = resolve(projectPath, JS_CONFIG_NAMES[0] as string);
atomicWriteFileSync(configPath, generateJsConfig(routes));
atomicWriteFileSync(configPath, generateJsConfig(routes, worktreeConfig));
}

// ── Validation ───────────────────────────────────────────────
Expand Down
133 changes: 84 additions & 49 deletions src/commands/migrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,27 @@ vi.mock("../cli/config-io.js", () => ({
resolveProjectConfigFile: resolveProjectConfigFileMock,
}));

const projectsList: { path: string; routes: Record<string, string> }[] = [];

vi.mock("../proxy/config.js", () => ({
config: {
get projects() {
return projectsList;
},
},
}));

vi.mock("../cli/output.js", () => ({
Header: () => null,
SuccessMessage: () => null,
ErrorMessage: () => null,
ExitOnRender: () => null,
}));

import { existsSync } from "node:fs";
import { existsSync, unlinkSync } from "node:fs";

const mockExistsSync = vi.mocked(existsSync);
const mockUnlinkSync = vi.mocked(unlinkSync);

const { __testing } = await import("./migrate.js");
const { migrateProject } = __testing;
Expand All @@ -59,22 +70,25 @@ beforeEach(() => {
writeProjectConfigMock.mockReset();
writeJsConfigMock.mockReset();
mockExistsSync.mockReset();
mockUnlinkSync.mockReset();
projectsList.length = 0;
});

describe("migrateProject", () => {
it('returns { status: "skipped-js-exists" } when JS config already exists', () => {
it('returns "skipped-no-legacy" when mjs exists and .dev-proxy.json does not', () => {
resolveProjectConfigFileMock.mockReturnValue({
type: "js",
path: "/p/dev-proxy.config.js",
path: "/p/dev-proxy.config.mjs",
});
mockExistsSync.mockReturnValue(false);

const result = migrateProject("/p");
expect(result).toEqual({ path: "/p", status: "skipped-js-exists" });
expect(result).toEqual({ path: "/p", status: "skipped-no-legacy" });
expect(writeJsConfigMock).not.toHaveBeenCalled();
expect(writeProjectConfigMock).not.toHaveBeenCalled();
});

it('returns { status: "skipped-no-json" } when .dev-proxy.json does not exist', () => {
it('returns "skipped-no-json" when neither mjs nor .dev-proxy.json exist', () => {
resolveProjectConfigFileMock.mockReturnValue(null);
mockExistsSync.mockReturnValue(false);

Expand All @@ -83,7 +97,7 @@ describe("migrateProject", () => {
expect(writeJsConfigMock).not.toHaveBeenCalled();
});

it('returns { status: "skipped-no-routes" } when JSON has no routes and no worktreeConfig', () => {
it('returns "skipped-no-routes" when JSON has no routes and no worktreeConfig', () => {
resolveProjectConfigFileMock.mockReturnValue(null);
mockExistsSync.mockReturnValue(true);
readProjectConfigMock.mockReturnValue({});
Expand All @@ -93,7 +107,7 @@ describe("migrateProject", () => {
expect(writeJsConfigMock).not.toHaveBeenCalled();
});

it('returns { status: "migrated" } and writes JS config when routes exist', () => {
it('returns "migrated" and writes mjs config when only routes exist', () => {
resolveProjectConfigFileMock.mockReturnValue(null);
mockExistsSync.mockReturnValue(true);
readProjectConfigMock.mockReturnValue({
Expand All @@ -103,36 +117,18 @@ describe("migrateProject", () => {

const result = migrateProject("/p");
expect(result).toEqual({ path: "/p", status: "migrated" });
expect(writeJsConfigMock).toHaveBeenCalledWith("/p", {
api: "http://localhost:4000",
web: "http://localhost:3000",
});
expect(writeJsConfigMock).toHaveBeenCalledWith(
"/p",
{ api: "http://localhost:4000", web: "http://localhost:3000" },
undefined,
);
expect(writeProjectConfigMock).toHaveBeenCalledWith("/p", {
worktrees: { feat1: { port: 5001 } },
});
expect(mockUnlinkSync).toHaveBeenCalled();
});

it("moves worktrees through writeProjectConfig (split into new file)", () => {
resolveProjectConfigFileMock.mockReturnValue(null);
mockExistsSync.mockReturnValue(true);
readProjectConfigMock.mockReturnValue({
routes: { app: "http://localhost:3000" },
worktrees: {
feat1: { port: 5001 },
feat2: { ports: { web: 5002, api: 5003 } },
},
});

migrateProject("/p");
expect(writeProjectConfigMock).toHaveBeenCalledWith("/p", {
worktrees: {
feat1: { port: 5001 },
feat2: { ports: { web: 5002, api: 5003 } },
},
});
});

it("preserves worktreeConfig alongside worktrees during migration", () => {
it("moves worktreeConfig into mjs as the third argument", () => {
resolveProjectConfigFileMock.mockReturnValue(null);
mockExistsSync.mockReturnValue(true);
const worktreeConfig = {
Expand All @@ -145,14 +141,20 @@ describe("migrateProject", () => {
worktreeConfig,
});

migrateProject("/p");
const result = migrateProject("/p");
expect(result).toEqual({ path: "/p", status: "migrated" });
expect(writeJsConfigMock).toHaveBeenCalledWith(
"/p",
{ app: "http://localhost:3000" },
worktreeConfig,
);
expect(writeProjectConfigMock).toHaveBeenCalledWith("/p", {
worktrees: { feat: { port: 5001 } },
worktreeConfig,
});
expect(mockUnlinkSync).toHaveBeenCalled();
});

it('returns "split-worktrees" when JS config exists and legacy file still has worktrees', () => {
it('returns "cleaned-legacy" when mjs exists and legacy file still has worktrees', () => {
resolveProjectConfigFileMock.mockReturnValue({
type: "js",
path: "/p/dev-proxy.config.mjs",
Expand All @@ -163,26 +165,56 @@ describe("migrateProject", () => {
});

const result = migrateProject("/p");
expect(result).toEqual({ path: "/p", status: "split-worktrees" });
expect(result).toEqual({ path: "/p", status: "cleaned-legacy" });
expect(writeProjectConfigMock).toHaveBeenCalledWith("/p", {
worktrees: { stale: { port: 6000 } },
});
expect(writeJsConfigMock).not.toHaveBeenCalled();
expect(mockUnlinkSync).toHaveBeenCalled();
});

it('still returns "skipped-js-exists" when JS config has no legacy file at all', () => {
it("moves leftover worktreeConfig from legacy file into mjs preserving routes", () => {
resolveProjectConfigFileMock.mockReturnValue({
type: "js",
path: "/p/dev-proxy.config.mjs",
});
mockExistsSync.mockReturnValue(false);
mockExistsSync.mockReturnValue(true);
const worktreeConfig = {
portRange: [4000, 5000] as [number, number],
directory: "../{branch}",
};
readProjectConfigMock.mockReturnValue({ worktreeConfig });
projectsList.push({
path: "/p",
routes: { api: "http://localhost:4000" },
});

const result = migrateProject("/p");
expect(result).toEqual({ path: "/p", status: "cleaned-legacy" });
expect(writeJsConfigMock).toHaveBeenCalledWith(
"/p",
{ api: "http://localhost:4000" },
worktreeConfig,
);
expect(mockUnlinkSync).toHaveBeenCalled();
});

it('returns "cleaned-legacy" without writes when legacy file is empty', () => {
resolveProjectConfigFileMock.mockReturnValue({
type: "js",
path: "/p/dev-proxy.config.mjs",
});
mockExistsSync.mockReturnValue(true);
readProjectConfigMock.mockReturnValue({});

const result = migrateProject("/p");
expect(result).toEqual({ path: "/p", status: "skipped-js-exists" });
expect(result).toEqual({ path: "/p", status: "cleaned-legacy" });
expect(writeProjectConfigMock).not.toHaveBeenCalled();
expect(writeJsConfigMock).not.toHaveBeenCalled();
expect(mockUnlinkSync).toHaveBeenCalled();
});

it("handles routes with wildcard correctly", () => {
it("handles wildcard routes correctly", () => {
resolveProjectConfigFileMock.mockReturnValue(null);
mockExistsSync.mockReturnValue(true);
readProjectConfigMock.mockReturnValue({
Expand All @@ -191,21 +223,24 @@ describe("migrateProject", () => {

const result = migrateProject("/p");
expect(result).toEqual({ path: "/p", status: "migrated" });
expect(writeJsConfigMock).toHaveBeenCalledWith("/p", {
api: "http://localhost:4000",
"*": "http://localhost:3000",
});
expect(writeProjectConfigMock).toHaveBeenCalledWith("/p", { worktrees: {} });
expect(writeJsConfigMock).toHaveBeenCalledWith(
"/p",
{ api: "http://localhost:4000", "*": "http://localhost:3000" },
undefined,
);
});

it('returns { status: "migrated" } when worktreeConfig exists even with no routes', () => {
it('returns "migrated" when worktreeConfig exists with no routes', () => {
resolveProjectConfigFileMock.mockReturnValue(null);
mockExistsSync.mockReturnValue(true);
readProjectConfigMock.mockReturnValue({
worktreeConfig: { portRange: [4000, 5000], directory: "../{branch}" },
});
const worktreeConfig = {
portRange: [4000, 5000] as [number, number],
directory: "../{branch}",
};
readProjectConfigMock.mockReturnValue({ worktreeConfig });

const result = migrateProject("/p");
expect(result).toEqual({ path: "/p", status: "migrated" });
expect(writeJsConfigMock).toHaveBeenCalledWith("/p", {}, worktreeConfig);
});
});
Loading