mirror of
https://github.com/openclaw/clawhub.git
synced 2026-07-21 11:15:26 -04:00
457 lines
13 KiB
TypeScript
457 lines
13 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import {
|
|
existsSync,
|
|
lstatSync,
|
|
mkdtempSync,
|
|
mkdirSync,
|
|
realpathSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
import { describeSetupResult, findSource, setupWorktree } from "./setup-worktree";
|
|
|
|
function writeWorktree(
|
|
path: string,
|
|
deploymentName: string,
|
|
options?: {
|
|
env?: Record<string, string | null>;
|
|
adminKey?: string;
|
|
ports?: { cloud: number; site?: number };
|
|
},
|
|
) {
|
|
const cloudPort = options?.ports?.cloud ?? 3210;
|
|
const sitePort = options?.ports?.site ?? cloudPort + 1;
|
|
const env: Record<string, string> = {
|
|
CONVEX_DEPLOYMENT: `local:${deploymentName}`,
|
|
VITE_CONVEX_URL: `http://127.0.0.1:${cloudPort}`,
|
|
VITE_CONVEX_SITE_URL: `http://127.0.0.1:${sitePort}`,
|
|
CONVEX_SITE_URL: `http://127.0.0.1:${sitePort}`,
|
|
};
|
|
|
|
for (const [key, value] of Object.entries(options?.env ?? {})) {
|
|
if (value === null) {
|
|
delete env[key];
|
|
} else {
|
|
env[key] = value;
|
|
}
|
|
}
|
|
|
|
mkdirSync(join(path, ".convex/local/default"), { recursive: true });
|
|
writeFileSync(
|
|
join(path, ".env.local"),
|
|
Object.entries(env)
|
|
.map(([key, value]) => `${key}=${value}`)
|
|
.join("\n") + "\n",
|
|
);
|
|
writeFileSync(
|
|
join(path, ".convex/local/default/config.json"),
|
|
JSON.stringify({
|
|
...(options?.adminKey ? { adminKey: options.adminKey } : {}),
|
|
deploymentName,
|
|
ports: options?.ports ?? { cloud: 3210, site: 3211 },
|
|
}),
|
|
);
|
|
}
|
|
|
|
function withSourceAndCurrent(run: (source: string, current: string) => void) {
|
|
const root = mkdtempSync(join(tmpdir(), "clawhub-worktree-"));
|
|
try {
|
|
const source = join(root, "source");
|
|
const current = join(root, "current");
|
|
mkdirSync(source);
|
|
mkdirSync(current);
|
|
run(source, current);
|
|
} finally {
|
|
rmSync(root, { force: true, recursive: true });
|
|
}
|
|
}
|
|
|
|
function runGit(cwd: string, args: string[]) {
|
|
const result = spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
if (result.status !== 0) {
|
|
throw new Error(`git ${args.join(" ")} failed: ${result.stderr || result.stdout}`);
|
|
}
|
|
}
|
|
|
|
describe("setup-worktree", () => {
|
|
it("describes copied local files and fallback links clearly", () => {
|
|
expect(
|
|
describeSetupResult({
|
|
convexLinked: false,
|
|
envLinked: false,
|
|
mode: "local",
|
|
sourcePath: "/repo",
|
|
}),
|
|
).toBe("validated copied local .env.local and .convex");
|
|
|
|
expect(
|
|
describeSetupResult({
|
|
convexLinked: true,
|
|
envLinked: false,
|
|
mode: "fallback",
|
|
sourcePath: "/repo",
|
|
}),
|
|
).toBe("fallback source /repo (env: existing, convex: linked)");
|
|
});
|
|
|
|
it("uses copied local worktree files without replacing them with symlinks", () => {
|
|
withSourceAndCurrent((_source, current) => {
|
|
writeWorktree(current, "local-clawhub");
|
|
|
|
expect(
|
|
setupWorktree({
|
|
cwd: current,
|
|
options: {
|
|
force: false,
|
|
from: null,
|
|
quiet: true,
|
|
},
|
|
}),
|
|
).toEqual({
|
|
convexLinked: false,
|
|
envLinked: false,
|
|
mode: "local",
|
|
sourcePath: current,
|
|
});
|
|
expect(lstatSync(join(current, ".env.local")).isSymbolicLink()).toBe(false);
|
|
expect(lstatSync(join(current, ".convex")).isSymbolicLink()).toBe(false);
|
|
});
|
|
});
|
|
|
|
it("keeps a copied env file and links only missing Convex state from a fallback source", () => {
|
|
withSourceAndCurrent((source, current) => {
|
|
writeWorktree(source, "local-clawhub");
|
|
mkdirSync(current, { recursive: true });
|
|
writeFileSync(
|
|
join(current, ".env.local"),
|
|
"CONVEX_DEPLOYMENT=local:local-clawhub\nVITE_CONVEX_URL=http://127.0.0.1:3210\nVITE_CONVEX_SITE_URL=http://127.0.0.1:3211\nCONVEX_SITE_URL=http://127.0.0.1:3211\n",
|
|
);
|
|
|
|
expect(
|
|
setupWorktree({
|
|
cwd: current,
|
|
options: {
|
|
force: false,
|
|
from: source,
|
|
quiet: true,
|
|
},
|
|
}),
|
|
).toEqual({
|
|
convexLinked: true,
|
|
envLinked: false,
|
|
mode: "fallback",
|
|
sourcePath: source,
|
|
});
|
|
expect(lstatSync(join(current, ".env.local")).isSymbolicLink()).toBe(false);
|
|
expect(lstatSync(join(current, ".convex")).isSymbolicLink()).toBe(true);
|
|
expect(existsSync(join(current, ".convex/local/default/config.json"))).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("keeps copied Convex state and links only a missing env file from a fallback source", () => {
|
|
withSourceAndCurrent((source, current) => {
|
|
writeWorktree(source, "local-clawhub");
|
|
mkdirSync(join(current, ".convex/local/default"), { recursive: true });
|
|
writeFileSync(
|
|
join(current, ".convex/local/default/config.json"),
|
|
JSON.stringify({ deploymentName: "local-clawhub", ports: { cloud: 3210, site: 3211 } }),
|
|
);
|
|
|
|
expect(
|
|
setupWorktree({
|
|
cwd: current,
|
|
options: {
|
|
force: false,
|
|
from: source,
|
|
quiet: true,
|
|
},
|
|
}),
|
|
).toEqual({
|
|
convexLinked: false,
|
|
envLinked: true,
|
|
mode: "fallback",
|
|
sourcePath: source,
|
|
});
|
|
expect(lstatSync(join(current, ".env.local")).isSymbolicLink()).toBe(true);
|
|
expect(lstatSync(join(current, ".convex")).isSymbolicLink()).toBe(false);
|
|
});
|
|
});
|
|
|
|
it("replaces stale copied state when forced by automated worktree setup", () => {
|
|
withSourceAndCurrent((source, current) => {
|
|
writeWorktree(source, "local-clawhub");
|
|
writeWorktree(current, "stale-clawhub", { ports: { cloud: 4321, site: 4322 } });
|
|
|
|
expect(
|
|
setupWorktree({
|
|
cwd: current,
|
|
options: {
|
|
force: true,
|
|
from: source,
|
|
quiet: true,
|
|
},
|
|
}),
|
|
).toEqual({
|
|
convexLinked: true,
|
|
envLinked: true,
|
|
mode: "fallback",
|
|
sourcePath: source,
|
|
});
|
|
expect(lstatSync(join(current, ".env.local")).isSymbolicLink()).toBe(true);
|
|
expect(lstatSync(join(current, ".convex")).isSymbolicLink()).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("prefers a fallback source over valid current copied state in automated worktree setup", () => {
|
|
const root = mkdtempSync(join(tmpdir(), "clawhub-worktree-git-"));
|
|
try {
|
|
const source = join(root, "source");
|
|
const current = join(root, "current");
|
|
mkdirSync(source);
|
|
runGit(source, ["init"]);
|
|
runGit(source, ["checkout", "-b", "main"]);
|
|
writeFileSync(join(source, "README.md"), "# test\n");
|
|
runGit(source, ["add", "README.md"]);
|
|
runGit(source, [
|
|
"-c",
|
|
"user.email=test@example.com",
|
|
"-c",
|
|
"user.name=Test User",
|
|
"commit",
|
|
"-m",
|
|
"init",
|
|
]);
|
|
runGit(source, ["worktree", "add", "-b", "feature", current]);
|
|
writeWorktree(source, "local-clawhub");
|
|
writeWorktree(current, "stale-clawhub", { ports: { cloud: 4321, site: 4322 } });
|
|
|
|
const result = setupWorktree({
|
|
cwd: current,
|
|
options: {
|
|
force: true,
|
|
preferFallback: true,
|
|
from: null,
|
|
quiet: true,
|
|
},
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
convexLinked: true,
|
|
envLinked: true,
|
|
mode: "fallback",
|
|
sourcePath: realpathSync(source),
|
|
});
|
|
expect(lstatSync(join(current, ".env.local")).isSymbolicLink()).toBe(true);
|
|
expect(lstatSync(join(current, ".convex")).isSymbolicLink()).toBe(true);
|
|
} finally {
|
|
rmSync(root, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
it("rejects an invalid copied env file instead of silently keeping it", () => {
|
|
withSourceAndCurrent((source, current) => {
|
|
writeWorktree(source, "local-clawhub");
|
|
writeFileSync(
|
|
join(current, ".env.local"),
|
|
"CONVEX_DEPLOYMENT=local:local-clawhub\nVITE_CONVEX_URL=http://127.0.0.1:3210\nVITE_CONVEX_SITE_URL=http://127.0.0.1:3210\nCONVEX_SITE_URL=http://127.0.0.1:3211\n",
|
|
);
|
|
|
|
expect(() =>
|
|
setupWorktree({
|
|
cwd: current,
|
|
options: {
|
|
force: false,
|
|
from: source,
|
|
quiet: true,
|
|
},
|
|
}),
|
|
).toThrow(".env.local already exists as a regular local path");
|
|
});
|
|
});
|
|
|
|
it("rejects a copied env file that does not match fallback runtime URLs", () => {
|
|
withSourceAndCurrent((source, current) => {
|
|
writeWorktree(source, "local-clawhub");
|
|
writeFileSync(
|
|
join(current, ".env.local"),
|
|
"CONVEX_DEPLOYMENT=local:local-clawhub\nVITE_CONVEX_URL=http://localhost:3210\nVITE_CONVEX_SITE_URL=http://127.0.0.1:3211\nCONVEX_SITE_URL=http://127.0.0.1:3211\n",
|
|
);
|
|
|
|
expect(() =>
|
|
setupWorktree({
|
|
cwd: current,
|
|
options: {
|
|
force: false,
|
|
from: source,
|
|
quiet: true,
|
|
},
|
|
}),
|
|
).toThrow(".env.local already exists as a regular local path");
|
|
});
|
|
});
|
|
|
|
it("rejects a copied Convex config that does not match the fallback admin key", () => {
|
|
withSourceAndCurrent((source, current) => {
|
|
writeWorktree(source, "local-clawhub", { adminKey: "source-admin-key" });
|
|
mkdirSync(join(current, ".convex/local/default"), { recursive: true });
|
|
writeFileSync(
|
|
join(current, ".convex/local/default/config.json"),
|
|
JSON.stringify({
|
|
adminKey: "stale-admin-key",
|
|
deploymentName: "local-clawhub",
|
|
ports: { cloud: 3210, site: 3211 },
|
|
}),
|
|
);
|
|
|
|
expect(() =>
|
|
setupWorktree({
|
|
cwd: current,
|
|
options: {
|
|
force: false,
|
|
from: source,
|
|
quiet: true,
|
|
},
|
|
}),
|
|
).toThrow(".convex already exists as a regular local path");
|
|
});
|
|
});
|
|
|
|
it("honors an explicit source even when the current worktree is already configured", () => {
|
|
const root = mkdtempSync(join(tmpdir(), "clawhub-worktree-"));
|
|
try {
|
|
const current = join(root, "current");
|
|
const explicit = join(root, "explicit");
|
|
mkdirSync(current);
|
|
mkdirSync(explicit);
|
|
writeWorktree(current, "current-clawhub");
|
|
writeWorktree(explicit, "explicit-clawhub");
|
|
|
|
expect(
|
|
findSource(
|
|
{
|
|
force: false,
|
|
from: explicit,
|
|
quiet: true,
|
|
},
|
|
current,
|
|
).path,
|
|
).toBe(explicit);
|
|
} finally {
|
|
rmSync(root, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
it("rejects local sources that point browser HTTP routes at the Convex function port", () => {
|
|
withSourceAndCurrent((source, current) => {
|
|
writeWorktree(source, "local-clawhub", {
|
|
env: { VITE_CONVEX_SITE_URL: "http://127.0.0.1:3210" },
|
|
});
|
|
|
|
expect(() =>
|
|
findSource(
|
|
{
|
|
force: false,
|
|
from: source,
|
|
quiet: true,
|
|
},
|
|
current,
|
|
),
|
|
).toThrow(
|
|
"VITE_CONVEX_SITE_URL port 3210 does not match .convex/local/default/config.json site port 3211",
|
|
);
|
|
});
|
|
});
|
|
|
|
it("rejects local sources without the server Convex site URL used by auth", () => {
|
|
withSourceAndCurrent((source, current) => {
|
|
writeWorktree(source, "local-clawhub", { env: { CONVEX_SITE_URL: null } });
|
|
|
|
expect(() =>
|
|
findSource(
|
|
{
|
|
force: false,
|
|
from: source,
|
|
quiet: true,
|
|
},
|
|
current,
|
|
),
|
|
).toThrow(
|
|
"CONVEX_SITE_URL is required for local Convex HTTP routes; set it to http://127.0.0.1:3211",
|
|
);
|
|
});
|
|
});
|
|
|
|
it("rejects local sources without the Convex function URL used by dev startup", () => {
|
|
withSourceAndCurrent((source, current) => {
|
|
writeWorktree(source, "local-clawhub", { env: { VITE_CONVEX_URL: null } });
|
|
|
|
expect(() =>
|
|
findSource(
|
|
{
|
|
force: false,
|
|
from: source,
|
|
quiet: true,
|
|
},
|
|
current,
|
|
),
|
|
).toThrow("VITE_CONVEX_URL is required for local Convex");
|
|
});
|
|
});
|
|
|
|
it("rejects local site URLs without an explicit site proxy port", () => {
|
|
withSourceAndCurrent((source, current) => {
|
|
writeWorktree(source, "local-clawhub", {
|
|
env: { VITE_CONVEX_SITE_URL: "http://127.0.0.1" },
|
|
});
|
|
|
|
expect(() =>
|
|
findSource(
|
|
{
|
|
force: false,
|
|
from: source,
|
|
quiet: true,
|
|
},
|
|
current,
|
|
),
|
|
).toThrow("VITE_CONVEX_SITE_URL must include local site proxy port 3211");
|
|
});
|
|
});
|
|
|
|
it("uses the configured local site port when it is not cloud port plus one", () => {
|
|
withSourceAndCurrent((source, current) => {
|
|
writeWorktree(source, "local-clawhub", { ports: { cloud: 3210, site: 4321 } });
|
|
|
|
expect(
|
|
findSource(
|
|
{
|
|
force: false,
|
|
from: source,
|
|
quiet: true,
|
|
},
|
|
current,
|
|
).path,
|
|
).toBe(source);
|
|
});
|
|
});
|
|
|
|
it("falls back to the next port for older local configs without a site port", () => {
|
|
withSourceAndCurrent((source, current) => {
|
|
writeWorktree(source, "local-clawhub", { ports: { cloud: 3210 } });
|
|
|
|
expect(
|
|
findSource(
|
|
{
|
|
force: false,
|
|
from: source,
|
|
quiet: true,
|
|
},
|
|
current,
|
|
).path,
|
|
).toBe(source);
|
|
});
|
|
});
|
|
});
|