mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 09:11:26 +00:00
refactor: dedupe core config and runtime helpers
This commit is contained in:
@@ -25,6 +25,24 @@ async function setupPairedOperatorDevice(baseDir: string, scopes: string[]) {
|
||||
await approveDevicePairing(request.request.requestId, baseDir);
|
||||
}
|
||||
|
||||
async function setupOperatorToken(scopes: string[]) {
|
||||
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
|
||||
await setupPairedOperatorDevice(baseDir, scopes);
|
||||
const paired = await getPairedDevice("device-1", baseDir);
|
||||
const token = requireToken(paired?.tokens?.operator?.token);
|
||||
return { baseDir, token };
|
||||
}
|
||||
|
||||
function verifyOperatorToken(params: { baseDir: string; token: string; scopes: string[] }) {
|
||||
return verifyDeviceToken({
|
||||
deviceId: "device-1",
|
||||
token: params.token,
|
||||
role: "operator",
|
||||
scopes: params.scopes,
|
||||
baseDir: params.baseDir,
|
||||
});
|
||||
}
|
||||
|
||||
function requireToken(token: string | undefined): string {
|
||||
expect(typeof token).toBe("string");
|
||||
if (typeof token !== "string") {
|
||||
@@ -163,71 +181,52 @@ describe("device pairing tokens", () => {
|
||||
});
|
||||
|
||||
test("verifies token and rejects mismatches", async () => {
|
||||
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
|
||||
await setupPairedOperatorDevice(baseDir, ["operator.read"]);
|
||||
const paired = await getPairedDevice("device-1", baseDir);
|
||||
const token = requireToken(paired?.tokens?.operator?.token);
|
||||
const { baseDir, token } = await setupOperatorToken(["operator.read"]);
|
||||
|
||||
const ok = await verifyDeviceToken({
|
||||
deviceId: "device-1",
|
||||
token,
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
const ok = await verifyOperatorToken({
|
||||
baseDir,
|
||||
token,
|
||||
scopes: ["operator.read"],
|
||||
});
|
||||
expect(ok.ok).toBe(true);
|
||||
|
||||
const mismatch = await verifyDeviceToken({
|
||||
deviceId: "device-1",
|
||||
token: "x".repeat(token.length),
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
const mismatch = await verifyOperatorToken({
|
||||
baseDir,
|
||||
token: "x".repeat(token.length),
|
||||
scopes: ["operator.read"],
|
||||
});
|
||||
expect(mismatch.ok).toBe(false);
|
||||
expect(mismatch.reason).toBe("token-mismatch");
|
||||
});
|
||||
|
||||
test("accepts operator.read/operator.write requests with an operator.admin token scope", async () => {
|
||||
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
|
||||
await setupPairedOperatorDevice(baseDir, ["operator.admin"]);
|
||||
const paired = await getPairedDevice("device-1", baseDir);
|
||||
const token = requireToken(paired?.tokens?.operator?.token);
|
||||
const { baseDir, token } = await setupOperatorToken(["operator.admin"]);
|
||||
|
||||
const readOk = await verifyDeviceToken({
|
||||
deviceId: "device-1",
|
||||
token,
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
const readOk = await verifyOperatorToken({
|
||||
baseDir,
|
||||
token,
|
||||
scopes: ["operator.read"],
|
||||
});
|
||||
expect(readOk.ok).toBe(true);
|
||||
|
||||
const writeOk = await verifyDeviceToken({
|
||||
deviceId: "device-1",
|
||||
token,
|
||||
role: "operator",
|
||||
scopes: ["operator.write"],
|
||||
const writeOk = await verifyOperatorToken({
|
||||
baseDir,
|
||||
token,
|
||||
scopes: ["operator.write"],
|
||||
});
|
||||
expect(writeOk.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("treats multibyte same-length token input as mismatch without throwing", async () => {
|
||||
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
|
||||
await setupPairedOperatorDevice(baseDir, ["operator.read"]);
|
||||
const paired = await getPairedDevice("device-1", baseDir);
|
||||
const token = requireToken(paired?.tokens?.operator?.token);
|
||||
const { baseDir, token } = await setupOperatorToken(["operator.read"]);
|
||||
const multibyteToken = "é".repeat(token.length);
|
||||
expect(Buffer.from(multibyteToken).length).not.toBe(Buffer.from(token).length);
|
||||
|
||||
await expect(
|
||||
verifyDeviceToken({
|
||||
deviceId: "device-1",
|
||||
token: multibyteToken,
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
verifyOperatorToken({
|
||||
baseDir,
|
||||
token: multibyteToken,
|
||||
scopes: ["operator.read"],
|
||||
}),
|
||||
).resolves.toEqual({ ok: false, reason: "token-mismatch" });
|
||||
});
|
||||
|
||||
@@ -91,6 +91,44 @@ function mockProcStatRead(params: { onProcRead: () => string }) {
|
||||
});
|
||||
}
|
||||
|
||||
async function writeLockFile(
|
||||
env: NodeJS.ProcessEnv,
|
||||
params: { startTime: number; createdAt?: string } = { startTime: 111 },
|
||||
) {
|
||||
const { lockPath, configPath } = resolveLockPath(env);
|
||||
const payload = createLockPayload({
|
||||
configPath,
|
||||
startTime: params.startTime,
|
||||
createdAt: params.createdAt,
|
||||
});
|
||||
await fs.writeFile(lockPath, JSON.stringify(payload), "utf8");
|
||||
return { lockPath, configPath };
|
||||
}
|
||||
|
||||
function createEaccesProcStatSpy() {
|
||||
return mockProcStatRead({
|
||||
onProcRead: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function acquireStaleLinuxLock(env: NodeJS.ProcessEnv) {
|
||||
await writeLockFile(env, {
|
||||
startTime: 111,
|
||||
createdAt: new Date(0).toISOString(),
|
||||
});
|
||||
const staleProcSpy = createEaccesProcStatSpy();
|
||||
const lock = await acquireForTest(env, {
|
||||
staleMs: 1,
|
||||
platform: "linux",
|
||||
});
|
||||
expect(lock).not.toBeNull();
|
||||
|
||||
await lock?.release();
|
||||
staleProcSpy.mockRestore();
|
||||
}
|
||||
|
||||
describe("gateway lock", () => {
|
||||
beforeAll(async () => {
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-lock-"));
|
||||
@@ -154,15 +192,8 @@ describe("gateway lock", () => {
|
||||
it("keeps lock on linux when proc access fails unless stale", async () => {
|
||||
vi.useRealTimers();
|
||||
const env = await makeEnv();
|
||||
const { lockPath, configPath } = resolveLockPath(env);
|
||||
const payload = createLockPayload({ configPath, startTime: 111 });
|
||||
await fs.writeFile(lockPath, JSON.stringify(payload), "utf8");
|
||||
|
||||
const spy = mockProcStatRead({
|
||||
onProcRead: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
});
|
||||
await writeLockFile(env);
|
||||
const spy = createEaccesProcStatSpy();
|
||||
|
||||
const pending = acquireForTest(env, {
|
||||
timeoutMs: 15,
|
||||
@@ -172,42 +203,14 @@ describe("gateway lock", () => {
|
||||
await expect(pending).rejects.toBeInstanceOf(GatewayLockError);
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
const stalePayload = createLockPayload({
|
||||
configPath,
|
||||
startTime: 111,
|
||||
createdAt: new Date(0).toISOString(),
|
||||
});
|
||||
await fs.writeFile(lockPath, JSON.stringify(stalePayload), "utf8");
|
||||
|
||||
const staleSpy = mockProcStatRead({
|
||||
onProcRead: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
});
|
||||
|
||||
const lock = await acquireForTest(env, {
|
||||
staleMs: 1,
|
||||
platform: "linux",
|
||||
});
|
||||
expect(lock).not.toBeNull();
|
||||
|
||||
await lock?.release();
|
||||
staleSpy.mockRestore();
|
||||
await acquireStaleLinuxLock(env);
|
||||
});
|
||||
|
||||
it("keeps lock when fs.stat fails until payload is stale", async () => {
|
||||
vi.useRealTimers();
|
||||
const env = await makeEnv();
|
||||
const { lockPath, configPath } = resolveLockPath(env);
|
||||
const payload = createLockPayload({ configPath, startTime: 111 });
|
||||
await fs.writeFile(lockPath, JSON.stringify(payload), "utf8");
|
||||
|
||||
const procSpy = mockProcStatRead({
|
||||
onProcRead: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
});
|
||||
await writeLockFile(env);
|
||||
const procSpy = createEaccesProcStatSpy();
|
||||
const statSpy = vi
|
||||
.spyOn(fs, "stat")
|
||||
.mockRejectedValue(Object.assign(new Error("EPERM"), { code: "EPERM" }));
|
||||
@@ -220,28 +223,7 @@ describe("gateway lock", () => {
|
||||
await expect(pending).rejects.toBeInstanceOf(GatewayLockError);
|
||||
|
||||
procSpy.mockRestore();
|
||||
|
||||
const stalePayload = createLockPayload({
|
||||
configPath,
|
||||
startTime: 111,
|
||||
createdAt: new Date(0).toISOString(),
|
||||
});
|
||||
await fs.writeFile(lockPath, JSON.stringify(stalePayload), "utf8");
|
||||
|
||||
const staleProcSpy = mockProcStatRead({
|
||||
onProcRead: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
});
|
||||
|
||||
const lock = await acquireForTest(env, {
|
||||
staleMs: 1,
|
||||
platform: "linux",
|
||||
});
|
||||
expect(lock).not.toBeNull();
|
||||
|
||||
await lock?.release();
|
||||
staleProcSpy.mockRestore();
|
||||
await acquireStaleLinuxLock(env);
|
||||
statSpy.mockRestore();
|
||||
});
|
||||
|
||||
|
||||
@@ -87,16 +87,35 @@ describe("Ghost reminder bug (issue #13317)", () => {
|
||||
result: Awaited<ReturnType<typeof runHeartbeatOnce>>;
|
||||
sendTelegram: ReturnType<typeof vi.fn>;
|
||||
calledCtx: { Provider?: string; Body?: string } | null;
|
||||
}> => {
|
||||
return runHeartbeatCase({
|
||||
tmpPrefix,
|
||||
replyText: "Relay this reminder now",
|
||||
reason: "cron:reminder-job",
|
||||
enqueue,
|
||||
});
|
||||
};
|
||||
|
||||
const runHeartbeatCase = async (params: {
|
||||
tmpPrefix: string;
|
||||
replyText: string;
|
||||
reason: string;
|
||||
enqueue: (sessionKey: string) => void;
|
||||
}): Promise<{
|
||||
result: Awaited<ReturnType<typeof runHeartbeatOnce>>;
|
||||
sendTelegram: ReturnType<typeof vi.fn>;
|
||||
calledCtx: { Provider?: string; Body?: string } | null;
|
||||
replyCallCount: number;
|
||||
}> => {
|
||||
return withTempHeartbeatSandbox(
|
||||
async ({ tmpDir, storePath }) => {
|
||||
const { sendTelegram, getReplySpy } = createHeartbeatDeps("Relay this reminder now");
|
||||
const { sendTelegram, getReplySpy } = createHeartbeatDeps(params.replyText);
|
||||
const { cfg, sessionKey } = await createConfig({ tmpDir, storePath });
|
||||
enqueue(sessionKey);
|
||||
params.enqueue(sessionKey);
|
||||
const result = await runHeartbeatOnce({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
reason: "cron:reminder-job",
|
||||
reason: params.reason,
|
||||
deps: {
|
||||
sendTelegram,
|
||||
},
|
||||
@@ -105,38 +124,32 @@ describe("Ghost reminder bug (issue #13317)", () => {
|
||||
Provider?: string;
|
||||
Body?: string;
|
||||
} | null;
|
||||
return { result, sendTelegram, calledCtx };
|
||||
return {
|
||||
result,
|
||||
sendTelegram,
|
||||
calledCtx,
|
||||
replyCallCount: getReplySpy.mock.calls.length,
|
||||
};
|
||||
},
|
||||
{ prefix: tmpPrefix },
|
||||
{ prefix: params.tmpPrefix },
|
||||
);
|
||||
};
|
||||
|
||||
it("does not use CRON_EVENT_PROMPT when only a HEARTBEAT_OK event is present", async () => {
|
||||
await withTempHeartbeatSandbox(
|
||||
async ({ tmpDir, storePath }) => {
|
||||
const { sendTelegram, getReplySpy } = createHeartbeatDeps("Heartbeat check-in");
|
||||
const { cfg, sessionKey } = await createConfig({ tmpDir, storePath });
|
||||
const { result, sendTelegram, calledCtx, replyCallCount } = await runHeartbeatCase({
|
||||
tmpPrefix: "openclaw-ghost-",
|
||||
replyText: "Heartbeat check-in",
|
||||
reason: "cron:test-job",
|
||||
enqueue: (sessionKey) => {
|
||||
enqueueSystemEvent("HEARTBEAT_OK", { sessionKey });
|
||||
|
||||
const result = await runHeartbeatOnce({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
reason: "cron:test-job",
|
||||
deps: {
|
||||
sendTelegram,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ran");
|
||||
expect(getReplySpy).toHaveBeenCalledTimes(1);
|
||||
const calledCtx = getReplySpy.mock.calls[0]?.[0];
|
||||
expect(calledCtx?.Provider).toBe("heartbeat");
|
||||
expect(calledCtx?.Body).not.toContain("scheduled reminder has been triggered");
|
||||
expect(calledCtx?.Body).not.toContain("relay this reminder");
|
||||
expect(sendTelegram).toHaveBeenCalled();
|
||||
},
|
||||
{ prefix: "openclaw-ghost-" },
|
||||
);
|
||||
});
|
||||
expect(result.status).toBe("ran");
|
||||
expect(replyCallCount).toBe(1);
|
||||
expect(calledCtx?.Provider).toBe("heartbeat");
|
||||
expect(calledCtx?.Body).not.toContain("scheduled reminder has been triggered");
|
||||
expect(calledCtx?.Body).not.toContain("relay this reminder");
|
||||
expect(sendTelegram).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses CRON_EVENT_PROMPT when an actionable cron event exists", async () => {
|
||||
@@ -165,34 +178,23 @@ describe("Ghost reminder bug (issue #13317)", () => {
|
||||
});
|
||||
|
||||
it("uses CRON_EVENT_PROMPT for tagged cron events on interval wake", async () => {
|
||||
await withTempHeartbeatSandbox(
|
||||
async ({ tmpDir, storePath }) => {
|
||||
const { sendTelegram, getReplySpy } = createHeartbeatDeps("Relay this cron update now");
|
||||
const { cfg, sessionKey } = await createConfig({ tmpDir, storePath });
|
||||
const { result, sendTelegram, calledCtx, replyCallCount } = await runHeartbeatCase({
|
||||
tmpPrefix: "openclaw-cron-interval-",
|
||||
replyText: "Relay this cron update now",
|
||||
reason: "interval",
|
||||
enqueue: (sessionKey) => {
|
||||
enqueueSystemEvent("Cron: QMD maintenance completed", {
|
||||
sessionKey,
|
||||
contextKey: "cron:qmd-maintenance",
|
||||
});
|
||||
|
||||
const result = await runHeartbeatOnce({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
reason: "interval",
|
||||
deps: {
|
||||
sendTelegram,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ran");
|
||||
expect(getReplySpy).toHaveBeenCalledTimes(1);
|
||||
const calledCtx = getReplySpy.mock.calls[0]?.[0];
|
||||
expect(calledCtx?.Provider).toBe("cron-event");
|
||||
expect(calledCtx?.Body).toContain("scheduled reminder has been triggered");
|
||||
expect(calledCtx?.Body).toContain("Cron: QMD maintenance completed");
|
||||
expect(calledCtx?.Body).not.toContain("Read HEARTBEAT.md");
|
||||
expect(sendTelegram).toHaveBeenCalled();
|
||||
},
|
||||
{ prefix: "openclaw-cron-interval-" },
|
||||
);
|
||||
});
|
||||
expect(result.status).toBe("ran");
|
||||
expect(replyCallCount).toBe(1);
|
||||
expect(calledCtx?.Provider).toBe("cron-event");
|
||||
expect(calledCtx?.Body).toContain("scheduled reminder has been triggered");
|
||||
expect(calledCtx?.Body).toContain("Cron: QMD maintenance completed");
|
||||
expect(calledCtx?.Body).not.toContain("Read HEARTBEAT.md");
|
||||
expect(sendTelegram).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,6 +79,27 @@ async function deliverWhatsAppPayload(params: {
|
||||
});
|
||||
}
|
||||
|
||||
async function runChunkedWhatsAppDelivery(params?: {
|
||||
mirror?: Parameters<typeof deliverOutboundPayloads>[0]["mirror"];
|
||||
}) {
|
||||
const sendWhatsApp = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ messageId: "w1", toJid: "jid" })
|
||||
.mockResolvedValueOnce({ messageId: "w2", toJid: "jid" });
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: { whatsapp: { textChunkLimit: 2 } },
|
||||
};
|
||||
const results = await deliverOutboundPayloads({
|
||||
cfg,
|
||||
channel: "whatsapp",
|
||||
to: "+1555",
|
||||
payloads: [{ text: "abcd" }],
|
||||
deps: { sendWhatsApp },
|
||||
...(params?.mirror ? { mirror: params.mirror } : {}),
|
||||
});
|
||||
return { sendWhatsApp, results };
|
||||
}
|
||||
|
||||
describe("deliverOutboundPayloads", () => {
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(defaultRegistry);
|
||||
@@ -238,21 +259,7 @@ describe("deliverOutboundPayloads", () => {
|
||||
});
|
||||
|
||||
it("chunks WhatsApp text and returns all results", async () => {
|
||||
const sendWhatsApp = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ messageId: "w1", toJid: "jid" })
|
||||
.mockResolvedValueOnce({ messageId: "w2", toJid: "jid" });
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: { whatsapp: { textChunkLimit: 2 } },
|
||||
};
|
||||
|
||||
const results = await deliverOutboundPayloads({
|
||||
cfg,
|
||||
channel: "whatsapp",
|
||||
to: "+1555",
|
||||
payloads: [{ text: "abcd" }],
|
||||
deps: { sendWhatsApp },
|
||||
});
|
||||
const { sendWhatsApp, results } = await runChunkedWhatsAppDelivery();
|
||||
|
||||
expect(sendWhatsApp).toHaveBeenCalledTimes(2);
|
||||
expect(results.map((r) => r.messageId)).toEqual(["w1", "w2"]);
|
||||
@@ -447,24 +454,12 @@ describe("deliverOutboundPayloads", () => {
|
||||
});
|
||||
|
||||
it("emits internal message:sent hook with success=true for chunked payload delivery", async () => {
|
||||
const sendWhatsApp = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ messageId: "w1", toJid: "jid" })
|
||||
.mockResolvedValueOnce({ messageId: "w2", toJid: "jid" });
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: { whatsapp: { textChunkLimit: 2 } },
|
||||
};
|
||||
|
||||
await deliverOutboundPayloads({
|
||||
cfg,
|
||||
channel: "whatsapp",
|
||||
to: "+1555",
|
||||
payloads: [{ text: "abcd" }],
|
||||
deps: { sendWhatsApp },
|
||||
const { sendWhatsApp } = await runChunkedWhatsAppDelivery({
|
||||
mirror: {
|
||||
sessionKey: "agent:main:main",
|
||||
},
|
||||
});
|
||||
expect(sendWhatsApp).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledTimes(1);
|
||||
expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledWith(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from "node:path";
|
||||
import { isPathInside } from "./path-guards.js";
|
||||
|
||||
export function resolveSafeBaseDir(rootDir: string): string {
|
||||
const resolved = path.resolve(rootDir);
|
||||
@@ -6,15 +7,5 @@ export function resolveSafeBaseDir(rootDir: string): string {
|
||||
}
|
||||
|
||||
export function isWithinDir(rootDir: string, targetPath: string): boolean {
|
||||
const resolvedRoot = path.resolve(rootDir);
|
||||
const resolvedTarget = path.resolve(targetPath);
|
||||
|
||||
// Windows paths are effectively case-insensitive; normalize to avoid false negatives.
|
||||
if (process.platform === "win32") {
|
||||
const relative = path.win32.relative(resolvedRoot.toLowerCase(), resolvedTarget.toLowerCase());
|
||||
return relative === "" || (!relative.startsWith("..") && !path.win32.isAbsolute(relative));
|
||||
}
|
||||
|
||||
const relative = path.relative(resolvedRoot, resolvedTarget);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
return isPathInside(rootDir, targetPath);
|
||||
}
|
||||
|
||||
@@ -123,32 +123,34 @@ describe("runGatewayUpdate", () => {
|
||||
return uiIndexPath;
|
||||
}
|
||||
|
||||
function buildStableTagResponses(stableTag: string): Record<string, CommandResponse> {
|
||||
function buildStableTagResponses(
|
||||
stableTag: string,
|
||||
options?: { additionalTags?: string[] },
|
||||
): Record<string, CommandResponse> {
|
||||
const tagOutput = [stableTag, ...(options?.additionalTags ?? [])].join("\n");
|
||||
return {
|
||||
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
|
||||
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
|
||||
[`git -C ${tempDir} status --porcelain -- :!dist/control-ui/`]: { stdout: "" },
|
||||
[`git -C ${tempDir} fetch --all --prune --tags`]: { stdout: "" },
|
||||
[`git -C ${tempDir} tag --list v* --sort=-v:refname`]: { stdout: `${stableTag}\n` },
|
||||
[`git -C ${tempDir} tag --list v* --sort=-v:refname`]: { stdout: `${tagOutput}\n` },
|
||||
[`git -C ${tempDir} checkout --detach ${stableTag}`]: { stdout: "" },
|
||||
};
|
||||
}
|
||||
|
||||
async function removeControlUiAssets() {
|
||||
await fs.rm(path.join(tempDir, "dist", "control-ui"), { recursive: true, force: true });
|
||||
function buildGitWorktreeProbeResponses(options?: { status?: string; branch?: string }) {
|
||||
return {
|
||||
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
|
||||
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
|
||||
[`git -C ${tempDir} rev-parse --abbrev-ref HEAD`]: { stdout: options?.branch ?? "main" },
|
||||
[`git -C ${tempDir} status --porcelain -- :!dist/control-ui/`]: {
|
||||
stdout: options?.status ?? "",
|
||||
},
|
||||
} satisfies Record<string, CommandResponse>;
|
||||
}
|
||||
|
||||
async function runWithRunner(
|
||||
runner: (argv: string[]) => Promise<CommandResult>,
|
||||
options?: { channel?: "stable" | "beta"; tag?: string; cwd?: string },
|
||||
) {
|
||||
return runGatewayUpdate({
|
||||
cwd: options?.cwd ?? tempDir,
|
||||
runCommand: async (argv, _runOptions) => runner(argv),
|
||||
timeoutMs: 5000,
|
||||
...(options?.channel ? { channel: options.channel } : {}),
|
||||
...(options?.tag ? { tag: options.tag } : {}),
|
||||
});
|
||||
async function removeControlUiAssets() {
|
||||
await fs.rm(path.join(tempDir, "dist", "control-ui"), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function runWithCommand(
|
||||
@@ -164,6 +166,13 @@ describe("runGatewayUpdate", () => {
|
||||
});
|
||||
}
|
||||
|
||||
async function runWithRunner(
|
||||
runner: (argv: string[]) => Promise<CommandResult>,
|
||||
options?: { channel?: "stable" | "beta"; tag?: string; cwd?: string },
|
||||
) {
|
||||
return runWithCommand(runner, options);
|
||||
}
|
||||
|
||||
async function seedGlobalPackageRoot(pkgRoot: string, version = "1.0.0") {
|
||||
await fs.mkdir(pkgRoot, { recursive: true });
|
||||
await fs.writeFile(
|
||||
@@ -176,10 +185,7 @@ describe("runGatewayUpdate", () => {
|
||||
it("skips git update when worktree is dirty", async () => {
|
||||
await setupGitCheckout();
|
||||
const { runner, calls } = createRunner({
|
||||
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
|
||||
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
|
||||
[`git -C ${tempDir} rev-parse --abbrev-ref HEAD`]: { stdout: "main" },
|
||||
[`git -C ${tempDir} status --porcelain -- :!dist/control-ui/`]: { stdout: " M README.md" },
|
||||
...buildGitWorktreeProbeResponses({ status: " M README.md" }),
|
||||
});
|
||||
|
||||
const result = await runWithRunner(runner);
|
||||
@@ -192,10 +198,7 @@ describe("runGatewayUpdate", () => {
|
||||
it("aborts rebase on failure", async () => {
|
||||
await setupGitCheckout();
|
||||
const { runner, calls } = createRunner({
|
||||
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
|
||||
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
|
||||
[`git -C ${tempDir} rev-parse --abbrev-ref HEAD`]: { stdout: "main" },
|
||||
[`git -C ${tempDir} status --porcelain -- :!dist/control-ui/`]: { stdout: "" },
|
||||
...buildGitWorktreeProbeResponses(),
|
||||
[`git -C ${tempDir} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`]: {
|
||||
stdout: "origin/main",
|
||||
},
|
||||
@@ -252,14 +255,7 @@ describe("runGatewayUpdate", () => {
|
||||
const stableTag = "v1.0.1-1";
|
||||
const betaTag = "v1.0.0-beta.2";
|
||||
const { runner, calls } = createRunner({
|
||||
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
|
||||
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
|
||||
[`git -C ${tempDir} status --porcelain -- :!dist/control-ui/`]: { stdout: "" },
|
||||
[`git -C ${tempDir} fetch --all --prune --tags`]: { stdout: "" },
|
||||
[`git -C ${tempDir} tag --list v* --sort=-v:refname`]: {
|
||||
stdout: `${stableTag}\n${betaTag}\n`,
|
||||
},
|
||||
[`git -C ${tempDir} checkout --detach ${stableTag}`]: { stdout: "" },
|
||||
...buildStableTagResponses(stableTag, { additionalTags: [betaTag] }),
|
||||
"pnpm install": { stdout: "" },
|
||||
"pnpm build": { stdout: "" },
|
||||
"pnpm ui:build": { stdout: "" },
|
||||
@@ -472,12 +468,7 @@ describe("runGatewayUpdate", () => {
|
||||
|
||||
const stableTag = "v1.0.1-1";
|
||||
const { runner } = createRunner({
|
||||
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
|
||||
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
|
||||
[`git -C ${tempDir} status --porcelain -- :!dist/control-ui/`]: { stdout: "" },
|
||||
[`git -C ${tempDir} fetch --all --prune --tags`]: { stdout: "" },
|
||||
[`git -C ${tempDir} tag --list v* --sort=-v:refname`]: { stdout: `${stableTag}\n` },
|
||||
[`git -C ${tempDir} checkout --detach ${stableTag}`]: { stdout: "" },
|
||||
...buildStableTagResponses(stableTag),
|
||||
"pnpm install": { stdout: "" },
|
||||
"pnpm build": { stdout: "" },
|
||||
"pnpm ui:build": { stdout: "" },
|
||||
|
||||
Reference in New Issue
Block a user