test: dedupe and optimize test suites

This commit is contained in:
Peter Steinberger
2026-02-19 15:18:50 +00:00
parent b0e55283d5
commit a1cb700a05
80 changed files with 2627 additions and 2962 deletions

View File

@@ -1,6 +1,6 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
type FakeFsEntry = { kind: "file"; content: string } | { kind: "dir" };
@@ -73,16 +73,32 @@ vi.mock("./openclaw-root.js", () => ({
resolveOpenClawPackageRootSync: vi.fn(() => null),
}));
let resolveControlUiRepoRoot: typeof import("./control-ui-assets.js").resolveControlUiRepoRoot;
let resolveControlUiDistIndexPath: typeof import("./control-ui-assets.js").resolveControlUiDistIndexPath;
let resolveControlUiDistIndexHealth: typeof import("./control-ui-assets.js").resolveControlUiDistIndexHealth;
let resolveControlUiRootOverrideSync: typeof import("./control-ui-assets.js").resolveControlUiRootOverrideSync;
let resolveControlUiRootSync: typeof import("./control-ui-assets.js").resolveControlUiRootSync;
let openclawRoot: typeof import("./openclaw-root.js");
describe("control UI assets helpers (fs-mocked)", () => {
beforeAll(async () => {
({
resolveControlUiRepoRoot,
resolveControlUiDistIndexPath,
resolveControlUiDistIndexHealth,
resolveControlUiRootOverrideSync,
resolveControlUiRootSync,
} = await import("./control-ui-assets.js"));
openclawRoot = await import("./openclaw-root.js");
});
beforeEach(() => {
state.entries.clear();
state.realpaths.clear();
vi.clearAllMocks();
});
it("resolves repo root from src argv1", async () => {
const { resolveControlUiRepoRoot } = await import("./control-ui-assets.js");
it("resolves repo root from src argv1", () => {
const root = abs("fixtures/ui-src");
setFile(path.join(root, "ui", "vite.config.ts"), "export {};\n");
@@ -90,9 +106,7 @@ describe("control UI assets helpers (fs-mocked)", () => {
expect(resolveControlUiRepoRoot(argv1)).toBe(root);
});
it("resolves repo root by traversing up (dist argv1)", async () => {
const { resolveControlUiRepoRoot } = await import("./control-ui-assets.js");
it("resolves repo root by traversing up (dist argv1)", () => {
const root = abs("fixtures/ui-dist");
setFile(path.join(root, "package.json"), "{}\n");
setFile(path.join(root, "ui", "vite.config.ts"), "export {};\n");
@@ -102,8 +116,6 @@ describe("control UI assets helpers (fs-mocked)", () => {
});
it("resolves dist control-ui index path for dist argv1", async () => {
const { resolveControlUiDistIndexPath } = await import("./control-ui-assets.js");
const argv1 = abs(path.join("fixtures", "pkg", "dist", "index.js"));
const distDir = path.dirname(argv1);
await expect(resolveControlUiDistIndexPath(argv1)).resolves.toBe(
@@ -112,9 +124,6 @@ describe("control UI assets helpers (fs-mocked)", () => {
});
it("uses resolveOpenClawPackageRoot when available", async () => {
const openclawRoot = await import("./openclaw-root.js");
const { resolveControlUiDistIndexPath } = await import("./control-ui-assets.js");
const pkgRoot = abs("fixtures/openclaw");
(
openclawRoot.resolveOpenClawPackageRoot as unknown as ReturnType<typeof vi.fn>
@@ -126,8 +135,6 @@ describe("control UI assets helpers (fs-mocked)", () => {
});
it("falls back to package.json name matching when root resolution fails", async () => {
const { resolveControlUiDistIndexPath } = await import("./control-ui-assets.js");
const root = abs("fixtures/fallback");
setFile(path.join(root, "package.json"), JSON.stringify({ name: "openclaw" }));
setFile(path.join(root, "dist", "control-ui", "index.html"), "<html></html>\n");
@@ -138,8 +145,6 @@ describe("control UI assets helpers (fs-mocked)", () => {
});
it("returns null when fallback package name does not match", async () => {
const { resolveControlUiDistIndexPath } = await import("./control-ui-assets.js");
const root = abs("fixtures/not-openclaw");
setFile(path.join(root, "package.json"), JSON.stringify({ name: "malicious-pkg" }));
setFile(path.join(root, "dist", "control-ui", "index.html"), "<html></html>\n");
@@ -148,8 +153,6 @@ describe("control UI assets helpers (fs-mocked)", () => {
});
it("reports health for missing + existing dist assets", async () => {
const { resolveControlUiDistIndexHealth } = await import("./control-ui-assets.js");
const root = abs("fixtures/health");
const indexPath = path.join(root, "dist", "control-ui", "index.html");
@@ -165,9 +168,7 @@ describe("control UI assets helpers (fs-mocked)", () => {
});
});
it("resolves control-ui root from override file or directory", async () => {
const { resolveControlUiRootOverrideSync } = await import("./control-ui-assets.js");
it("resolves control-ui root from override file or directory", () => {
const root = abs("fixtures/override");
const uiDir = path.join(root, "dist", "control-ui");
const indexPath = path.join(uiDir, "index.html");
@@ -181,9 +182,6 @@ describe("control UI assets helpers (fs-mocked)", () => {
});
it("resolves control-ui root for dist bundle argv1 and moduleUrl candidates", async () => {
const openclawRoot = await import("./openclaw-root.js");
const { resolveControlUiRootSync } = await import("./control-ui-assets.js");
const pkgRoot = abs("fixtures/openclaw-bundle");
(
openclawRoot.resolveOpenClawPackageRootSync as unknown as ReturnType<typeof vi.fn>

View File

@@ -1,6 +1,6 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
type FakeFsEntry = { kind: "file"; content: string } | { kind: "dir" };
@@ -90,6 +90,14 @@ vi.mock("node:fs/promises", async (importOriginal) => {
});
describe("resolveOpenClawPackageRoot", () => {
let resolveOpenClawPackageRoot: typeof import("./openclaw-root.js").resolveOpenClawPackageRoot;
let resolveOpenClawPackageRootSync: typeof import("./openclaw-root.js").resolveOpenClawPackageRootSync;
beforeAll(async () => {
({ resolveOpenClawPackageRoot, resolveOpenClawPackageRootSync } =
await import("./openclaw-root.js"));
});
beforeEach(() => {
state.entries.clear();
state.realpaths.clear();
@@ -97,8 +105,6 @@ describe("resolveOpenClawPackageRoot", () => {
});
it("resolves package root from .bin argv1", async () => {
const { resolveOpenClawPackageRootSync } = await import("./openclaw-root.js");
const project = fx("bin-scenario");
const argv1 = path.join(project, "node_modules", ".bin", "openclaw");
const pkgRoot = path.join(project, "node_modules", "openclaw");
@@ -108,8 +114,6 @@ describe("resolveOpenClawPackageRoot", () => {
});
it("resolves package root via symlinked argv1", async () => {
const { resolveOpenClawPackageRootSync } = await import("./openclaw-root.js");
const project = fx("symlink-scenario");
const bin = path.join(project, "bin", "openclaw");
const realPkg = path.join(project, "real-pkg");
@@ -132,8 +136,6 @@ describe("resolveOpenClawPackageRoot", () => {
});
it("prefers moduleUrl candidates", async () => {
const { resolveOpenClawPackageRootSync } = await import("./openclaw-root.js");
const pkgRoot = fx("moduleurl");
setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "openclaw" }));
const moduleUrl = pathToFileURL(path.join(pkgRoot, "dist", "index.js")).toString();
@@ -142,8 +144,6 @@ describe("resolveOpenClawPackageRoot", () => {
});
it("returns null for non-openclaw package roots", async () => {
const { resolveOpenClawPackageRootSync } = await import("./openclaw-root.js");
const pkgRoot = fx("not-openclaw");
setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "not-openclaw" }));
@@ -151,8 +151,6 @@ describe("resolveOpenClawPackageRoot", () => {
});
it("async resolver matches sync behavior", async () => {
const { resolveOpenClawPackageRoot } = await import("./openclaw-root.js");
const pkgRoot = fx("async");
setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "openclaw" }));

View File

@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { slackPlugin } from "../../../extensions/slack/src/channel.js";
import { telegramPlugin } from "../../../extensions/telegram/src/channel.js";
import { whatsappPlugin } from "../../../extensions/whatsapp/src/channel.js";
@@ -86,16 +86,32 @@ function createAlwaysConfiguredPluginConfig(account: Record<string, unknown> = {
};
}
describe("runMessageAction context isolation", () => {
beforeEach(async () => {
const { createPluginRuntime } = await import("../../plugins/runtime/index.js");
const { setSlackRuntime } = await import("../../../extensions/slack/src/runtime.js");
const { setTelegramRuntime } = await import("../../../extensions/telegram/src/runtime.js");
const { setWhatsAppRuntime } = await import("../../../extensions/whatsapp/src/runtime.js");
const runtime = createPluginRuntime();
setSlackRuntime(runtime);
let createPluginRuntime: typeof import("../../plugins/runtime/index.js").createPluginRuntime;
let setSlackRuntime: typeof import("../../../extensions/slack/src/runtime.js").setSlackRuntime;
let setTelegramRuntime: typeof import("../../../extensions/telegram/src/runtime.js").setTelegramRuntime;
let setWhatsAppRuntime: typeof import("../../../extensions/whatsapp/src/runtime.js").setWhatsAppRuntime;
function installChannelRuntimes(params?: { includeTelegram?: boolean; includeWhatsApp?: boolean }) {
const runtime = createPluginRuntime();
setSlackRuntime(runtime);
if (params?.includeTelegram !== false) {
setTelegramRuntime(runtime);
}
if (params?.includeWhatsApp !== false) {
setWhatsAppRuntime(runtime);
}
}
describe("runMessageAction context isolation", () => {
beforeAll(async () => {
({ createPluginRuntime } = await import("../../plugins/runtime/index.js"));
({ setSlackRuntime } = await import("../../../extensions/slack/src/runtime.js"));
({ setTelegramRuntime } = await import("../../../extensions/telegram/src/runtime.js"));
({ setWhatsAppRuntime } = await import("../../../extensions/whatsapp/src/runtime.js"));
});
beforeEach(() => {
installChannelRuntimes();
setActivePluginRegistry(
createTestRegistry([
{
@@ -222,59 +238,59 @@ describe("runMessageAction context isolation", () => {
expect(result.kind).toBe("action");
});
it("allows WhatsApp send when target matches current chat", async () => {
it.each([
{
name: "whatsapp",
channel: "whatsapp",
target: "123@g.us",
currentChannelId: "123@g.us",
},
{
name: "imessage",
channel: "imessage",
target: "imessage:+15551234567",
currentChannelId: "imessage:+15551234567",
},
] as const)("allows $name send when target matches current context", async (testCase) => {
const result = await runDrySend({
cfg: whatsappConfig,
actionParams: {
channel: "whatsapp",
target: "123@g.us",
channel: testCase.channel,
target: testCase.target,
message: "hi",
},
toolContext: { currentChannelId: "123@g.us" },
toolContext: { currentChannelId: testCase.currentChannelId },
});
expect(result.kind).toBe("send");
});
it("blocks WhatsApp send when target differs from current chat", async () => {
it.each([
{
name: "whatsapp",
channel: "whatsapp",
target: "456@g.us",
currentChannelId: "123@g.us",
currentChannelProvider: "whatsapp",
},
{
name: "imessage",
channel: "imessage",
target: "imessage:+15551230000",
currentChannelId: "imessage:+15551234567",
currentChannelProvider: "imessage",
},
] as const)("blocks $name send when target differs from current context", async (testCase) => {
const result = await runDrySend({
cfg: whatsappConfig,
actionParams: {
channel: "whatsapp",
target: "456@g.us",
message: "hi",
},
toolContext: { currentChannelId: "123@g.us", currentChannelProvider: "whatsapp" },
});
expect(result.kind).toBe("send");
});
it("allows iMessage send when target matches current handle", async () => {
const result = await runDrySend({
cfg: whatsappConfig,
actionParams: {
channel: "imessage",
target: "imessage:+15551234567",
message: "hi",
},
toolContext: { currentChannelId: "imessage:+15551234567" },
});
expect(result.kind).toBe("send");
});
it("blocks iMessage send when target differs from current handle", async () => {
const result = await runDrySend({
cfg: whatsappConfig,
actionParams: {
channel: "imessage",
target: "imessage:+15551230000",
channel: testCase.channel,
target: testCase.target,
message: "hi",
},
toolContext: {
currentChannelId: "imessage:+15551234567",
currentChannelProvider: "imessage",
currentChannelId: testCase.currentChannelId,
currentChannelProvider: testCase.currentChannelProvider,
},
});
@@ -498,11 +514,8 @@ describe("runMessageAction sendAttachment hydration", () => {
});
describe("runMessageAction sandboxed media validation", () => {
beforeEach(async () => {
const { createPluginRuntime } = await import("../../plugins/runtime/index.js");
const { setSlackRuntime } = await import("../../../extensions/slack/src/runtime.js");
const runtime = createPluginRuntime();
setSlackRuntime(runtime);
beforeEach(() => {
installChannelRuntimes({ includeTelegram: false, includeWhatsApp: false });
setActivePluginRegistry(
createTestRegistry([
{
@@ -518,38 +531,38 @@ describe("runMessageAction sandboxed media validation", () => {
setActivePluginRegistry(createTestRegistry([]));
});
it("rejects media outside the sandbox root", async () => {
await withSandbox(async (sandboxDir) => {
await expect(
runDrySend({
cfg: slackConfig,
actionParams: {
channel: "slack",
target: "#C12345678",
media: "/etc/passwd",
message: "",
},
sandboxRoot: sandboxDir,
}),
).rejects.toThrow(/sandbox/i);
});
});
it.each(["/etc/passwd", "file:///etc/passwd"])(
"rejects out-of-sandbox media reference: %s",
async (media) => {
await withSandbox(async (sandboxDir) => {
await expect(
runDrySend({
cfg: slackConfig,
actionParams: {
channel: "slack",
target: "#C12345678",
media,
message: "",
},
sandboxRoot: sandboxDir,
}),
).rejects.toThrow(/sandbox/i);
});
},
);
it("rejects file:// media outside the sandbox root", async () => {
await withSandbox(async (sandboxDir) => {
await expect(
runDrySend({
cfg: slackConfig,
actionParams: {
channel: "slack",
target: "#C12345678",
media: "file:///etc/passwd",
message: "",
},
sandboxRoot: sandboxDir,
}),
).rejects.toThrow(/sandbox/i);
});
it("rejects data URLs in media params", async () => {
await expect(
runDrySend({
cfg: slackConfig,
actionParams: {
channel: "slack",
target: "#C12345678",
media: "data:image/png;base64,abcd",
message: "",
},
}),
).rejects.toThrow(/data:/i);
});
it("rewrites sandbox-relative media paths", async () => {
@@ -592,20 +605,6 @@ describe("runMessageAction sandboxed media validation", () => {
expect(result.sendResult?.mediaUrl).toBe(path.join(sandboxDir, "data", "note.ogg"));
});
});
it("rejects data URLs in media params", async () => {
await expect(
runDrySend({
cfg: slackConfig,
actionParams: {
channel: "slack",
target: "#C12345678",
media: "data:image/png;base64,abcd",
message: "",
},
}),
).rejects.toThrow(/data:/i);
});
});
describe("runMessageAction media caption behavior", () => {

View File

@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { slackPlugin } from "../../../extensions/slack/src/channel.js";
import { telegramPlugin } from "../../../extensions/telegram/src/channel.js";
import type { OpenClawConfig } from "../../config/config.js";
@@ -80,11 +80,18 @@ const defaultTelegramToolContext = {
currentThreadTs: "42",
} as const;
let createPluginRuntime: typeof import("../../plugins/runtime/index.js").createPluginRuntime;
let setSlackRuntime: typeof import("../../../extensions/slack/src/runtime.js").setSlackRuntime;
let setTelegramRuntime: typeof import("../../../extensions/telegram/src/runtime.js").setTelegramRuntime;
describe("runMessageAction threading auto-injection", () => {
beforeEach(async () => {
const { createPluginRuntime } = await import("../../plugins/runtime/index.js");
const { setSlackRuntime } = await import("../../../extensions/slack/src/runtime.js");
const { setTelegramRuntime } = await import("../../../extensions/telegram/src/runtime.js");
beforeAll(async () => {
({ createPluginRuntime } = await import("../../plugins/runtime/index.js"));
({ setSlackRuntime } = await import("../../../extensions/slack/src/runtime.js"));
({ setTelegramRuntime } = await import("../../../extensions/telegram/src/runtime.js"));
});
beforeEach(() => {
const runtime = createPluginRuntime();
setSlackRuntime(runtime);
setTelegramRuntime(runtime);
@@ -110,94 +117,73 @@ describe("runMessageAction threading auto-injection", () => {
mocks.recordSessionMetaFromInbound.mockReset();
});
it("uses toolContext thread when auto-threading is active", async () => {
it.each([
{
name: "exact channel id",
target: "channel:C123",
threadTs: "111.222",
expectedSessionKey: "agent:main:slack:channel:c123:thread:111.222",
},
{
name: "case-insensitive channel id",
target: "channel:c123",
threadTs: "333.444",
expectedSessionKey: "agent:main:slack:channel:c123:thread:333.444",
},
] as const)("auto-threads slack using $name", async (testCase) => {
mockHandledSendAction();
const call = await runThreadingAction({
cfg: slackConfig,
actionParams: {
channel: "slack",
target: "channel:C123",
target: testCase.target,
message: "hi",
},
toolContext: {
currentChannelId: "C123",
currentThreadTs: "111.222",
currentThreadTs: testCase.threadTs,
replyToMode: "all",
},
});
expect(call?.ctx?.agentId).toBe("main");
expect(call?.ctx?.mirror?.sessionKey).toBe("agent:main:slack:channel:c123:thread:111.222");
expect(call?.ctx?.mirror?.sessionKey).toBe(testCase.expectedSessionKey);
});
it("matches auto-threading when channel ids differ in case", async () => {
mockHandledSendAction();
const call = await runThreadingAction({
cfg: slackConfig,
actionParams: {
channel: "slack",
target: "channel:c123",
message: "hi",
},
toolContext: {
currentChannelId: "C123",
currentThreadTs: "333.444",
replyToMode: "all",
},
});
expect(call?.ctx?.mirror?.sessionKey).toBe("agent:main:slack:channel:c123:thread:333.444");
});
it("auto-injects telegram threadId from toolContext when omitted", async () => {
it.each([
{
name: "injects threadId for matching target",
target: "telegram:123",
expectedThreadId: "42",
},
{
name: "injects threadId for prefixed group target",
target: "telegram:group:123",
expectedThreadId: "42",
},
{
name: "skips threadId when target chat differs",
target: "telegram:999",
expectedThreadId: undefined,
},
] as const)("telegram auto-threading: $name", async (testCase) => {
mockHandledSendAction();
const call = await runThreadingAction({
cfg: telegramConfig,
actionParams: {
channel: "telegram",
target: "telegram:123",
target: testCase.target,
message: "hi",
},
toolContext: defaultTelegramToolContext,
});
expect(call?.threadId).toBe("42");
expect(call?.ctx?.params?.threadId).toBe("42");
});
it("skips telegram auto-threading when target chat differs", async () => {
mockHandledSendAction();
const call = await runThreadingAction({
cfg: telegramConfig,
actionParams: {
channel: "telegram",
target: "telegram:999",
message: "hi",
},
toolContext: defaultTelegramToolContext,
});
expect(call?.ctx?.params?.threadId).toBeUndefined();
});
it("matches telegram target with internal prefix variations", async () => {
mockHandledSendAction();
const call = await runThreadingAction({
cfg: telegramConfig,
actionParams: {
channel: "telegram",
target: "telegram:group:123",
message: "hi",
},
toolContext: defaultTelegramToolContext,
});
expect(call?.ctx?.params?.threadId).toBe("42");
expect(call?.ctx?.params?.threadId).toBe(testCase.expectedThreadId);
if (testCase.expectedThreadId !== undefined) {
expect(call?.threadId).toBe(testCase.expectedThreadId);
}
});
it("uses explicit telegram threadId when provided", async () => {

View File

@@ -1,6 +1,6 @@
import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
import { beforeAll, describe, expect, it, vi } from "vitest";
type MockSpawnChild = EventEmitter & {
stdout?: EventEmitter & { setEncoding?: (enc: string) => void };
@@ -40,9 +40,15 @@ vi.mock("node:child_process", () => {
const spawnMock = vi.mocked(spawn);
let parseSshConfigOutput: typeof import("./ssh-config.js").parseSshConfigOutput;
let resolveSshConfig: typeof import("./ssh-config.js").resolveSshConfig;
describe("ssh-config", () => {
it("parses ssh -G output", async () => {
const { parseSshConfigOutput } = await import("./ssh-config.js");
beforeAll(async () => {
({ parseSshConfigOutput, resolveSshConfig } = await import("./ssh-config.js"));
});
it("parses ssh -G output", () => {
const parsed = parseSshConfigOutput(
"user bob\nhostname example.com\nport 2222\nidentityfile none\nidentityfile /tmp/id\n",
);
@@ -53,7 +59,6 @@ describe("ssh-config", () => {
});
it("resolves ssh config via ssh -G", async () => {
const { resolveSshConfig } = await import("./ssh-config.js");
const config = await resolveSshConfig({ user: "me", host: "alias", port: 22 });
expect(config?.user).toBe("steipete");
expect(config?.host).toBe("peters-mac-studio-1.sheep-coho.ts.net");
@@ -74,7 +79,6 @@ describe("ssh-config", () => {
},
);
const { resolveSshConfig } = await import("./ssh-config.js");
const config = await resolveSshConfig({ user: "me", host: "bad-host", port: 22 });
expect(config).toBeNull();
});

View File

@@ -12,6 +12,10 @@ vi.mock("./backoff.js", () => ({
},
}));
function createRuntime() {
return { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
}
describe("waitForTransportReady", () => {
beforeEach(() => {
vi.useFakeTimers();
@@ -22,7 +26,7 @@ describe("waitForTransportReady", () => {
});
it("returns when the check succeeds and logs after the delay", async () => {
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const runtime = createRuntime();
let attempts = 0;
const readyPromise = waitForTransportReady({
label: "test transport",
@@ -48,7 +52,7 @@ describe("waitForTransportReady", () => {
});
it("throws after the timeout", async () => {
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const runtime = createRuntime();
const waitPromise = waitForTransportReady({
label: "test transport",
timeoutMs: 110,
@@ -65,7 +69,7 @@ describe("waitForTransportReady", () => {
});
it("returns early when aborted", async () => {
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const runtime = createRuntime();
const controller = new AbortController();
controller.abort();
await waitForTransportReady({

View File

@@ -147,22 +147,23 @@ describe("update-startup", () => {
return { log, parsed };
}
it("logs update hint for npm installs when newer tag exists", async () => {
const { log, parsed } = await runUpdateCheckAndReadState("stable");
it.each([
{
name: "stable channel",
channel: "stable" as const,
},
{
name: "beta channel with older beta tag",
channel: "beta" as const,
},
])("logs latest update hint for $name", async ({ channel }) => {
const { log, parsed } = await runUpdateCheckAndReadState(channel);
expect(log.info).toHaveBeenCalledWith(
expect.stringContaining("update available (latest): v2.0.0"),
);
expect(parsed.lastNotifiedVersion).toBe("2.0.0");
expect(parsed.lastAvailableVersion).toBe("2.0.0");
});
it("uses latest when beta tag is older than release", async () => {
const { log, parsed } = await runUpdateCheckAndReadState("beta");
expect(log.info).toHaveBeenCalledWith(
expect.stringContaining("update available (latest): v2.0.0"),
);
expect(parsed.lastNotifiedTag).toBe("latest");
});