mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-20 13:41:30 +00:00
619 lines
19 KiB
TypeScript
619 lines
19 KiB
TypeScript
import chokidar from "chokidar";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { listChannelPlugins } from "../channels/plugins/index.js";
|
|
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
|
import type { ConfigFileSnapshot, ConfigWriteNotification } from "../config/config.js";
|
|
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
|
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
|
import {
|
|
buildGatewayReloadPlan,
|
|
diffConfigPaths,
|
|
resolveGatewayReloadSettings,
|
|
startGatewayConfigReloader,
|
|
} from "./config-reload.js";
|
|
|
|
describe("diffConfigPaths", () => {
|
|
it("captures nested config changes", () => {
|
|
const prev = { hooks: { gmail: { account: "a" } } };
|
|
const next = { hooks: { gmail: { account: "b" } } };
|
|
const paths = diffConfigPaths(prev, next);
|
|
expect(paths).toContain("hooks.gmail.account");
|
|
});
|
|
|
|
it("captures array changes", () => {
|
|
const prev = { messages: { groupChat: { mentionPatterns: ["a"] } } };
|
|
const next = { messages: { groupChat: { mentionPatterns: ["b"] } } };
|
|
const paths = diffConfigPaths(prev, next);
|
|
expect(paths).toContain("messages.groupChat.mentionPatterns");
|
|
});
|
|
|
|
it("does not report unchanged arrays of objects as changed", () => {
|
|
const prev = {
|
|
memory: {
|
|
qmd: {
|
|
paths: [{ path: "~/docs", pattern: "**/*.md", name: "docs" }],
|
|
scope: {
|
|
rules: [{ when: { channel: "slack" }, include: ["docs"] }],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const next = {
|
|
memory: {
|
|
qmd: {
|
|
paths: [{ path: "~/docs", pattern: "**/*.md", name: "docs" }],
|
|
scope: {
|
|
rules: [{ when: { channel: "slack" }, include: ["docs"] }],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
expect(diffConfigPaths(prev, next)).toEqual([]);
|
|
});
|
|
|
|
it("reports changed arrays of objects", () => {
|
|
const prev = {
|
|
memory: {
|
|
qmd: {
|
|
paths: [{ path: "~/docs", pattern: "**/*.md", name: "docs" }],
|
|
},
|
|
},
|
|
};
|
|
const next = {
|
|
memory: {
|
|
qmd: {
|
|
paths: [{ path: "~/docs", pattern: "**/*.txt", name: "docs" }],
|
|
},
|
|
},
|
|
};
|
|
expect(diffConfigPaths(prev, next)).toContain("memory.qmd.paths");
|
|
});
|
|
|
|
it("collapses changed agents.list heartbeat entries to agents.list", () => {
|
|
const prev = {
|
|
agents: {
|
|
list: [{ id: "ops", heartbeat: { every: "5m", lightContext: false } }],
|
|
},
|
|
};
|
|
const next = {
|
|
agents: {
|
|
list: [{ id: "ops", heartbeat: { every: "5m", lightContext: true } }],
|
|
},
|
|
};
|
|
|
|
expect(diffConfigPaths(prev, next)).toEqual(["agents.list"]);
|
|
});
|
|
});
|
|
|
|
describe("buildGatewayReloadPlan", () => {
|
|
const emptyRegistry = createTestRegistry([]);
|
|
const telegramPlugin: ChannelPlugin = {
|
|
id: "telegram",
|
|
meta: {
|
|
id: "telegram",
|
|
label: "Telegram",
|
|
selectionLabel: "Telegram",
|
|
docsPath: "/channels/telegram",
|
|
blurb: "test",
|
|
},
|
|
capabilities: { chatTypes: ["direct"] },
|
|
config: {
|
|
listAccountIds: () => [],
|
|
resolveAccount: () => ({}),
|
|
},
|
|
reload: { configPrefixes: ["channels.telegram"] },
|
|
};
|
|
const whatsappPlugin: ChannelPlugin = {
|
|
id: "whatsapp",
|
|
meta: {
|
|
id: "whatsapp",
|
|
label: "WhatsApp",
|
|
selectionLabel: "WhatsApp",
|
|
docsPath: "/channels/whatsapp",
|
|
blurb: "test",
|
|
},
|
|
capabilities: { chatTypes: ["direct"] },
|
|
config: {
|
|
listAccountIds: () => [],
|
|
resolveAccount: () => ({}),
|
|
},
|
|
reload: { configPrefixes: ["web"], noopPrefixes: ["channels.whatsapp"] },
|
|
};
|
|
const registry = createTestRegistry([
|
|
{ pluginId: "telegram", plugin: telegramPlugin, source: "test" },
|
|
{ pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" },
|
|
]);
|
|
registry.reloads = [
|
|
{
|
|
pluginId: "browser",
|
|
pluginName: "Browser",
|
|
registration: { restartPrefixes: ["browser"] },
|
|
source: "test",
|
|
},
|
|
];
|
|
|
|
beforeEach(() => {
|
|
setActivePluginRegistry(registry);
|
|
});
|
|
|
|
afterEach(() => {
|
|
setActivePluginRegistry(emptyRegistry);
|
|
});
|
|
|
|
it("marks gateway changes as restart required", () => {
|
|
const plan = buildGatewayReloadPlan(["gateway.port"]);
|
|
expect(plan.restartGateway).toBe(true);
|
|
expect(plan.restartReasons).toContain("gateway.port");
|
|
});
|
|
|
|
it("restarts the gateway for browser plugin config changes", () => {
|
|
const plan = buildGatewayReloadPlan(["browser.enabled"]);
|
|
expect(plan.restartGateway).toBe(true);
|
|
expect(plan.restartReasons).toContain("browser.enabled");
|
|
expect(plan.hotReasons).toEqual([]);
|
|
});
|
|
|
|
it("restarts the Gmail watcher for hooks.gmail changes", () => {
|
|
const plan = buildGatewayReloadPlan(["hooks.gmail.account"]);
|
|
expect(plan.restartGateway).toBe(false);
|
|
expect(plan.restartGmailWatcher).toBe(true);
|
|
expect(plan.reloadHooks).toBe(true);
|
|
});
|
|
|
|
it("restarts providers when provider config prefixes change", () => {
|
|
const changedPaths = ["web.enabled", "channels.telegram.botToken"];
|
|
const plan = buildGatewayReloadPlan(changedPaths);
|
|
expect(plan.restartGateway).toBe(false);
|
|
const expected = new Set(
|
|
listChannelPlugins()
|
|
.filter((plugin) =>
|
|
(plugin.reload?.configPrefixes ?? []).some((prefix) =>
|
|
changedPaths.some((path) => path === prefix || path.startsWith(`${prefix}.`)),
|
|
),
|
|
)
|
|
.map((plugin) => plugin.id),
|
|
);
|
|
expect(expected.size).toBeGreaterThan(0);
|
|
expect(plan.restartChannels).toEqual(expected);
|
|
});
|
|
|
|
it("restarts heartbeat when model-related config changes", () => {
|
|
const plan = buildGatewayReloadPlan([
|
|
"models.providers.openai.models",
|
|
"agents.defaults.model",
|
|
]);
|
|
expect(plan.restartGateway).toBe(false);
|
|
expect(plan.restartHeartbeat).toBe(true);
|
|
expect(plan.hotReasons).toEqual(
|
|
expect.arrayContaining(["models.providers.openai.models", "agents.defaults.model"]),
|
|
);
|
|
});
|
|
|
|
it("restarts heartbeat when agents.defaults.models allowlist changes", () => {
|
|
const plan = buildGatewayReloadPlan(["agents.defaults.models"]);
|
|
expect(plan.restartGateway).toBe(false);
|
|
expect(plan.restartHeartbeat).toBe(true);
|
|
expect(plan.hotReasons).toContain("agents.defaults.models");
|
|
expect(plan.noopPaths).toEqual([]);
|
|
});
|
|
|
|
it("restarts heartbeat when agents.list entries change", () => {
|
|
const plan = buildGatewayReloadPlan(["agents.list"]);
|
|
expect(plan.restartGateway).toBe(false);
|
|
expect(plan.restartHeartbeat).toBe(true);
|
|
expect(plan.hotReasons).toContain("agents.list");
|
|
expect(plan.noopPaths).toEqual([]);
|
|
});
|
|
|
|
it("hot-reloads health monitor when channelHealthCheckMinutes changes", () => {
|
|
const plan = buildGatewayReloadPlan(["gateway.channelHealthCheckMinutes"]);
|
|
expect(plan.restartGateway).toBe(false);
|
|
expect(plan.restartHealthMonitor).toBe(true);
|
|
expect(plan.hotReasons).toContain("gateway.channelHealthCheckMinutes");
|
|
});
|
|
|
|
it("treats gateway.remote as no-op", () => {
|
|
const plan = buildGatewayReloadPlan(["gateway.remote.url"]);
|
|
expect(plan.restartGateway).toBe(false);
|
|
expect(plan.noopPaths).toContain("gateway.remote.url");
|
|
});
|
|
|
|
it("treats secrets config changes as no-op for gateway restart planning", () => {
|
|
const plan = buildGatewayReloadPlan(["secrets.providers.default.path"]);
|
|
expect(plan.restartGateway).toBe(false);
|
|
expect(plan.noopPaths).toContain("secrets.providers.default.path");
|
|
});
|
|
|
|
it("treats diagnostics.stuckSessionWarnMs as no-op for gateway restart planning", () => {
|
|
const plan = buildGatewayReloadPlan(["diagnostics.stuckSessionWarnMs"]);
|
|
expect(plan.restartGateway).toBe(false);
|
|
expect(plan.noopPaths).toContain("diagnostics.stuckSessionWarnMs");
|
|
});
|
|
|
|
it("restarts for gateway.auth.token changes", () => {
|
|
const plan = buildGatewayReloadPlan(["gateway.auth.token"]);
|
|
expect(plan.restartGateway).toBe(true);
|
|
expect(plan.restartReasons).toContain("gateway.auth.token");
|
|
});
|
|
|
|
it("restarts for gateway.auth.mode changes", () => {
|
|
const plan = buildGatewayReloadPlan(["gateway.auth.mode"]);
|
|
expect(plan.restartGateway).toBe(true);
|
|
expect(plan.restartReasons).toContain("gateway.auth.mode");
|
|
});
|
|
|
|
it("defaults unknown paths to restart", () => {
|
|
const plan = buildGatewayReloadPlan(["unknownField"]);
|
|
expect(plan.restartGateway).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
path: "browser.enabled",
|
|
expectRestartGateway: true,
|
|
expectRestartReason: "browser.enabled",
|
|
},
|
|
{
|
|
path: "gateway.channelHealthCheckMinutes",
|
|
expectRestartGateway: false,
|
|
expectHotPath: "gateway.channelHealthCheckMinutes",
|
|
expectRestartHealthMonitor: true,
|
|
},
|
|
{
|
|
path: "hooks.gmail.account",
|
|
expectRestartGateway: false,
|
|
expectHotPath: "hooks.gmail.account",
|
|
expectRestartGmailWatcher: true,
|
|
expectReloadHooks: true,
|
|
},
|
|
{
|
|
path: "agents.list",
|
|
expectRestartGateway: false,
|
|
expectHotPath: "agents.list",
|
|
expectRestartHeartbeat: true,
|
|
},
|
|
{
|
|
path: "gateway.remote.url",
|
|
expectRestartGateway: false,
|
|
expectNoopPath: "gateway.remote.url",
|
|
},
|
|
{
|
|
path: "gateway.auth.token",
|
|
expectRestartGateway: true,
|
|
expectRestartReason: "gateway.auth.token",
|
|
},
|
|
{
|
|
path: "unknownField",
|
|
expectRestartGateway: true,
|
|
expectRestartReason: "unknownField",
|
|
},
|
|
])("classifies reload path: $path", (testCase) => {
|
|
const plan = buildGatewayReloadPlan([testCase.path]);
|
|
expect(plan.restartGateway).toBe(testCase.expectRestartGateway);
|
|
if (testCase.expectHotPath) {
|
|
expect(plan.hotReasons).toContain(testCase.expectHotPath);
|
|
}
|
|
if (testCase.expectNoopPath) {
|
|
expect(plan.noopPaths).toContain(testCase.expectNoopPath);
|
|
}
|
|
if (testCase.expectRestartReason) {
|
|
expect(plan.restartReasons).toContain(testCase.expectRestartReason);
|
|
}
|
|
if (testCase.expectRestartHealthMonitor) {
|
|
expect(plan.restartHealthMonitor).toBe(true);
|
|
}
|
|
if (testCase.expectRestartGmailWatcher) {
|
|
expect(plan.restartGmailWatcher).toBe(true);
|
|
}
|
|
if (testCase.expectReloadHooks) {
|
|
expect(plan.reloadHooks).toBe(true);
|
|
}
|
|
if (testCase.expectRestartHeartbeat) {
|
|
expect(plan.restartHeartbeat).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("resolveGatewayReloadSettings", () => {
|
|
it("uses defaults when unset", () => {
|
|
const settings = resolveGatewayReloadSettings({});
|
|
expect(settings.mode).toBe("hybrid");
|
|
expect(settings.debounceMs).toBe(300);
|
|
});
|
|
});
|
|
|
|
type WatcherHandler = () => void;
|
|
type WatcherEvent = "add" | "change" | "unlink" | "error";
|
|
|
|
function createWatcherMock() {
|
|
const handlers = new Map<WatcherEvent, WatcherHandler[]>();
|
|
return {
|
|
on(event: WatcherEvent, handler: WatcherHandler) {
|
|
const existing = handlers.get(event) ?? [];
|
|
existing.push(handler);
|
|
handlers.set(event, existing);
|
|
return this;
|
|
},
|
|
emit(event: WatcherEvent) {
|
|
for (const handler of handlers.get(event) ?? []) {
|
|
handler();
|
|
}
|
|
},
|
|
close: vi.fn(async () => {}),
|
|
};
|
|
}
|
|
|
|
function makeSnapshot(partial: Partial<ConfigFileSnapshot> = {}): ConfigFileSnapshot {
|
|
return {
|
|
path: "/tmp/openclaw.json",
|
|
exists: true,
|
|
raw: "{}",
|
|
parsed: {},
|
|
sourceConfig: {},
|
|
resolved: {},
|
|
valid: true,
|
|
runtimeConfig: {},
|
|
config: {},
|
|
issues: [],
|
|
warnings: [],
|
|
legacyIssues: [],
|
|
...partial,
|
|
};
|
|
}
|
|
|
|
function createReloaderHarness(
|
|
readSnapshot: () => Promise<ConfigFileSnapshot>,
|
|
options: { initialInternalWriteHash?: string | null } = {},
|
|
) {
|
|
const watcher = createWatcherMock();
|
|
vi.spyOn(chokidar, "watch").mockReturnValue(watcher as unknown as never);
|
|
const onHotReload = vi.fn(async () => {});
|
|
const onRestart = vi.fn();
|
|
let writeListener: ((event: ConfigWriteNotification) => void) | null = null;
|
|
const subscribeToWrites = vi.fn((listener: (event: ConfigWriteNotification) => void) => {
|
|
writeListener = listener;
|
|
return () => {
|
|
if (writeListener === listener) {
|
|
writeListener = null;
|
|
}
|
|
};
|
|
});
|
|
const log = {
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
};
|
|
const reloader = startGatewayConfigReloader({
|
|
initialConfig: { gateway: { reload: { debounceMs: 0 } } },
|
|
initialInternalWriteHash: options.initialInternalWriteHash,
|
|
readSnapshot,
|
|
subscribeToWrites,
|
|
onHotReload,
|
|
onRestart,
|
|
log,
|
|
watchPath: "/tmp/openclaw.json",
|
|
});
|
|
return {
|
|
watcher,
|
|
onHotReload,
|
|
onRestart,
|
|
log,
|
|
reloader,
|
|
emitWrite(event: ConfigWriteNotification) {
|
|
writeListener?.(event);
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("startGatewayConfigReloader", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("retries missing snapshots and reloads once config file reappears", async () => {
|
|
const readSnapshot = vi
|
|
.fn<() => Promise<ConfigFileSnapshot>>()
|
|
.mockResolvedValueOnce(makeSnapshot({ exists: false, raw: null, hash: "missing-1" }))
|
|
.mockResolvedValueOnce(
|
|
makeSnapshot({
|
|
config: {
|
|
gateway: { reload: { debounceMs: 0 } },
|
|
hooks: { enabled: true },
|
|
},
|
|
hash: "next-1",
|
|
}),
|
|
);
|
|
const { watcher, onHotReload, onRestart, log, reloader } = createReloaderHarness(readSnapshot);
|
|
|
|
watcher.emit("unlink");
|
|
await vi.runOnlyPendingTimersAsync();
|
|
await vi.advanceTimersByTimeAsync(150);
|
|
|
|
expect(readSnapshot).toHaveBeenCalledTimes(2);
|
|
expect(onHotReload).toHaveBeenCalledTimes(1);
|
|
expect(onRestart).not.toHaveBeenCalled();
|
|
expect(log.info).toHaveBeenCalledWith("config reload retry (1/2): config file not found");
|
|
expect(log.warn).not.toHaveBeenCalledWith("config reload skipped (config file not found)");
|
|
|
|
await reloader.stop();
|
|
});
|
|
|
|
it("caps missing-file retries and skips reload after retry budget is exhausted", async () => {
|
|
const readSnapshot = vi
|
|
.fn<() => Promise<ConfigFileSnapshot>>()
|
|
.mockResolvedValue(makeSnapshot({ exists: false, raw: null, hash: "missing" }));
|
|
const { watcher, onHotReload, onRestart, log, reloader } = createReloaderHarness(readSnapshot);
|
|
|
|
watcher.emit("unlink");
|
|
await vi.runAllTimersAsync();
|
|
|
|
expect(readSnapshot).toHaveBeenCalledTimes(3);
|
|
expect(onHotReload).not.toHaveBeenCalled();
|
|
expect(onRestart).not.toHaveBeenCalled();
|
|
expect(log.warn).toHaveBeenCalledWith("config reload skipped (config file not found)");
|
|
|
|
await reloader.stop();
|
|
});
|
|
|
|
it("contains restart callback failures and retries on subsequent changes", async () => {
|
|
const readSnapshot = vi
|
|
.fn<() => Promise<ConfigFileSnapshot>>()
|
|
.mockResolvedValueOnce(
|
|
makeSnapshot({
|
|
config: {
|
|
gateway: { reload: { debounceMs: 0 }, port: 18790 },
|
|
},
|
|
hash: "restart-1",
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
makeSnapshot({
|
|
config: {
|
|
gateway: { reload: { debounceMs: 0 }, port: 18791 },
|
|
},
|
|
hash: "restart-2",
|
|
}),
|
|
);
|
|
const { watcher, onHotReload, onRestart, log, reloader } = createReloaderHarness(readSnapshot);
|
|
onRestart.mockRejectedValueOnce(new Error("restart-check failed"));
|
|
onRestart.mockResolvedValueOnce(undefined);
|
|
|
|
const unhandled: unknown[] = [];
|
|
const onUnhandled = (reason: unknown) => {
|
|
unhandled.push(reason);
|
|
};
|
|
process.on("unhandledRejection", onUnhandled);
|
|
try {
|
|
watcher.emit("change");
|
|
await vi.runOnlyPendingTimersAsync();
|
|
await Promise.resolve();
|
|
|
|
expect(onHotReload).not.toHaveBeenCalled();
|
|
expect(onRestart).toHaveBeenCalledTimes(1);
|
|
expect(log.error).toHaveBeenCalledWith("config restart failed: Error: restart-check failed");
|
|
expect(unhandled).toEqual([]);
|
|
|
|
watcher.emit("change");
|
|
await vi.runOnlyPendingTimersAsync();
|
|
await Promise.resolve();
|
|
|
|
expect(onRestart).toHaveBeenCalledTimes(2);
|
|
expect(unhandled).toEqual([]);
|
|
} finally {
|
|
process.off("unhandledRejection", onUnhandled);
|
|
await reloader.stop();
|
|
}
|
|
});
|
|
|
|
it("reuses in-process write notifications and dedupes watcher rereads by persisted hash", async () => {
|
|
const readSnapshot = vi
|
|
.fn<() => Promise<ConfigFileSnapshot>>()
|
|
.mockResolvedValueOnce(
|
|
makeSnapshot({
|
|
sourceConfig: {
|
|
gateway: { reload: { debounceMs: 0 } },
|
|
},
|
|
runtimeConfig: {
|
|
gateway: { reload: { debounceMs: 0 } },
|
|
hooks: { enabled: true },
|
|
},
|
|
config: {
|
|
gateway: { reload: { debounceMs: 0 } },
|
|
hooks: { enabled: true },
|
|
},
|
|
hash: "internal-1",
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
makeSnapshot({
|
|
sourceConfig: {
|
|
gateway: { reload: { debounceMs: 0 }, port: 19001 },
|
|
},
|
|
runtimeConfig: {
|
|
gateway: { reload: { debounceMs: 0 }, port: 19001 },
|
|
},
|
|
config: {
|
|
gateway: { reload: { debounceMs: 0 }, port: 19001 },
|
|
},
|
|
hash: "external-1",
|
|
}),
|
|
);
|
|
const harness = createReloaderHarness(readSnapshot);
|
|
|
|
harness.emitWrite({
|
|
configPath: "/tmp/openclaw.json",
|
|
sourceConfig: { gateway: { reload: { debounceMs: 0 } } },
|
|
runtimeConfig: {
|
|
gateway: { reload: { debounceMs: 0 } },
|
|
hooks: { enabled: true },
|
|
},
|
|
persistedHash: "internal-1",
|
|
writtenAtMs: Date.now(),
|
|
});
|
|
await vi.runOnlyPendingTimersAsync();
|
|
|
|
expect(readSnapshot).not.toHaveBeenCalled();
|
|
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
|
|
|
|
harness.watcher.emit("change");
|
|
harness.watcher.emit("change");
|
|
await vi.runOnlyPendingTimersAsync();
|
|
|
|
expect(readSnapshot).toHaveBeenCalledTimes(1);
|
|
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
|
|
|
|
harness.watcher.emit("change");
|
|
await vi.runOnlyPendingTimersAsync();
|
|
|
|
expect(readSnapshot).toHaveBeenCalledTimes(2);
|
|
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
|
|
expect(harness.onRestart).toHaveBeenCalledTimes(1);
|
|
|
|
await harness.reloader.stop();
|
|
});
|
|
|
|
it("dedupes the first watcher reread for startup internal writes", async () => {
|
|
const readSnapshot = vi
|
|
.fn<() => Promise<ConfigFileSnapshot>>()
|
|
.mockResolvedValueOnce(
|
|
makeSnapshot({
|
|
config: {
|
|
gateway: { reload: { debounceMs: 0 }, auth: { mode: "token", token: "startup" } },
|
|
},
|
|
hash: "startup-internal-1",
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
makeSnapshot({
|
|
config: {
|
|
gateway: { reload: { debounceMs: 0 }, port: 19001 },
|
|
},
|
|
hash: "external-after-startup-1",
|
|
}),
|
|
);
|
|
const harness = createReloaderHarness(readSnapshot, {
|
|
initialInternalWriteHash: "startup-internal-1",
|
|
});
|
|
|
|
harness.watcher.emit("change");
|
|
await vi.runOnlyPendingTimersAsync();
|
|
|
|
expect(readSnapshot).toHaveBeenCalledTimes(1);
|
|
expect(harness.onHotReload).not.toHaveBeenCalled();
|
|
expect(harness.onRestart).not.toHaveBeenCalled();
|
|
|
|
harness.watcher.emit("change");
|
|
await vi.runOnlyPendingTimersAsync();
|
|
|
|
expect(readSnapshot).toHaveBeenCalledTimes(2);
|
|
expect(harness.onRestart).toHaveBeenCalledTimes(1);
|
|
|
|
await harness.reloader.stop();
|
|
});
|
|
});
|