mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-12 09:24:24 +00:00
277 lines
9.9 KiB
TypeScript
277 lines
9.9 KiB
TypeScript
import { installBrowserLockFixture } from "../testSupport/browserLockFixture";
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import React from "react";
|
|
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
|
import type { MonitorPanel } from "../../domain/monitorPanels";
|
|
import type { CloudProvider, SyncResult, SyncPayload } from "../../domain/sync";
|
|
|
|
installBrowserLockFixture();
|
|
|
|
const store = new Map<string, string>();
|
|
const storage = {
|
|
getItem: (key: string) => store.get(key) ?? null,
|
|
setItem: (key: string, value: string) => {
|
|
store.set(key, value);
|
|
},
|
|
removeItem: (key: string) => {
|
|
store.delete(key);
|
|
},
|
|
};
|
|
const events = new EventTarget();
|
|
for (const key of [
|
|
"addEventListener",
|
|
"removeEventListener",
|
|
"dispatchEvent",
|
|
] as const) {
|
|
Object.defineProperty(globalThis, key, {
|
|
configurable: true,
|
|
value: events[key].bind(events),
|
|
});
|
|
}
|
|
const windowFixture = Object.assign(events, {
|
|
localStorage: storage,
|
|
CustomEvent,
|
|
setInterval,
|
|
clearInterval,
|
|
});
|
|
Object.defineProperty(globalThis, "localStorage", {
|
|
configurable: true,
|
|
value: storage,
|
|
});
|
|
Object.defineProperty(globalThis, "window", {
|
|
configurable: true,
|
|
value: windowFixture,
|
|
});
|
|
Object.defineProperty(globalThis, "document", {
|
|
configurable: true,
|
|
value: Object.assign(new EventTarget(), { visibilityState: "hidden" }),
|
|
});
|
|
(
|
|
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }
|
|
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
const { useAutoSync } = await import("./useAutoSync");
|
|
const { getCloudSyncManager } =
|
|
await import("../../infrastructure/services/CloudSyncManager");
|
|
const { saveMonitorPanel, deleteMonitorPanel, writeMonitorPanels } =
|
|
await import("./monitorPanelsStorage");
|
|
|
|
test("normal manual and debounced auto-sync upload panel creation, edits and deletion", async () => {
|
|
const manager = getCloudSyncManager();
|
|
const snapshot = {
|
|
...manager.getState(),
|
|
securityState: "UNLOCKED" as const,
|
|
syncState: "IDLE" as const,
|
|
autoSyncEnabled: true,
|
|
providers: {
|
|
...manager.getState().providers,
|
|
webdav: { provider: "webdav" as const, status: "connected" as const },
|
|
},
|
|
};
|
|
const uploads: SyncPayload[] = [];
|
|
const originals = {
|
|
getState: manager.getState,
|
|
syncAllProviders: manager.syncAllProviders,
|
|
loadSyncBase: manager.loadSyncBase,
|
|
inspectProviderRemote: manager.inspectProviderRemote,
|
|
setPendingLocalSync: manager.setPendingLocalSync,
|
|
setAvailablePluginSyncProviders: manager.setAvailablePluginSyncProviders,
|
|
};
|
|
manager.getState = () => snapshot;
|
|
manager.loadSyncBase = async () => null;
|
|
manager.inspectProviderRemote = async () => ({
|
|
remoteFile: null,
|
|
payload: null,
|
|
remoteChanged: false,
|
|
});
|
|
manager.syncAllProviders = async (payload) => {
|
|
uploads.push(structuredClone(payload));
|
|
return new Map<CloudProvider, SyncResult>([
|
|
["webdav", { provider: "webdav", success: true, action: "upload" }],
|
|
]);
|
|
};
|
|
manager.setPendingLocalSync = () => {};
|
|
manager.setAvailablePluginSyncProviders = () => {};
|
|
const panel: MonitorPanel = {
|
|
id: "p",
|
|
name: "Fleet",
|
|
description: "",
|
|
hostIds: ["h1", "h2"],
|
|
view: "charts",
|
|
interval: 10,
|
|
range: 15,
|
|
sort: "manual",
|
|
};
|
|
let api: ReturnType<typeof useAutoSync> | undefined;
|
|
let renderer: ReactTestRenderer | undefined;
|
|
const apply = async () => {};
|
|
const Probe = () => {
|
|
api = useAutoSync({
|
|
hosts: [],
|
|
keys: [],
|
|
snippets: [],
|
|
customGroups: ["fixture"],
|
|
startupReady: true,
|
|
onApplyPayload: apply,
|
|
onApplyConvergentPayload: apply,
|
|
});
|
|
return null;
|
|
};
|
|
const waitForUpload = async (count: number) => {
|
|
// Flush the adapter's batched event, then observe the actual debounced
|
|
// upload. Slow CI must not be mistaken for an incomplete collection.
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
});
|
|
if (uploads.length === count) {
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 3500));
|
|
});
|
|
} else {
|
|
const deadline = Date.now() + 10000;
|
|
while (uploads.length < count && Date.now() < deadline)
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
});
|
|
}
|
|
assert.equal(
|
|
uploads.length,
|
|
count,
|
|
"a panel-only change must alter the auto-sync hash and upload",
|
|
);
|
|
};
|
|
try {
|
|
writeMonitorPanels([
|
|
{ ...panel, stats: { cpu: 42 }, points: [1], running: true },
|
|
]);
|
|
await act(async () => {
|
|
renderer = create(React.createElement(Probe));
|
|
});
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
});
|
|
await act(async () => {
|
|
assert.equal(await api!.syncNow({ trigger: "manual" }), true);
|
|
});
|
|
assert.deepEqual(uploads[0].monitorPanels, [panel]);
|
|
const renamed = { ...panel, name: "Renamed" };
|
|
await act(async () => {
|
|
await saveMonitorPanel(panel, renamed);
|
|
});
|
|
await waitForUpload(2);
|
|
assert.deepEqual(uploads[1].monitorPanels, [renamed]);
|
|
const edited = {
|
|
...renamed,
|
|
hostIds: ["h2"],
|
|
view: "list" as const,
|
|
interval: 30 as const,
|
|
};
|
|
await act(async () => {
|
|
await saveMonitorPanel(renamed, edited);
|
|
});
|
|
await waitForUpload(3);
|
|
assert.deepEqual(uploads[2].monitorPanels, [edited]);
|
|
await act(async () => {
|
|
await deleteMonitorPanel(panel.id);
|
|
});
|
|
await waitForUpload(4);
|
|
assert.deepEqual(uploads[3].monitorPanels, []);
|
|
await act(async () => {
|
|
await saveMonitorPanel(null, panel);
|
|
});
|
|
await waitForUpload(5);
|
|
assert.deepEqual(uploads[4].monitorPanels, [panel]);
|
|
assert.ok(
|
|
uploads.every((p) => !JSON.stringify(p.monitorPanels).includes("stats")),
|
|
);
|
|
await act(async () => {
|
|
await saveMonitorPanel(panel, panel);
|
|
});
|
|
await waitForUpload(5);
|
|
} finally {
|
|
await act(async () => {
|
|
renderer?.unmount();
|
|
});
|
|
Object.assign(manager, originals);
|
|
}
|
|
});
|
|
|
|
test("pending-sync indication coalesces edit bursts without repeatedly collecting the vault", async () => {
|
|
const manager = getCloudSyncManager();
|
|
const snapshot = {
|
|
...manager.getState(),
|
|
securityState: "UNLOCKED" as const,
|
|
syncState: "IDLE" as const,
|
|
autoSyncEnabled: true,
|
|
providers: {
|
|
...manager.getState().providers,
|
|
webdav: { provider: "webdav" as const, status: "connected" as const },
|
|
},
|
|
};
|
|
const originals = {
|
|
getState: manager.getState,
|
|
loadSyncBase: manager.loadSyncBase,
|
|
inspectProviderRemote: manager.inspectProviderRemote,
|
|
setPendingLocalSync: manager.setPendingLocalSync,
|
|
setAvailablePluginSyncProviders: manager.setAvailablePluginSyncProviders,
|
|
};
|
|
manager.getState = () => snapshot;
|
|
manager.loadSyncBase = async () => null;
|
|
manager.inspectProviderRemote = async () => ({ remoteFile: null, payload: null, remoteChanged: false });
|
|
const pending: boolean[] = [];
|
|
manager.setPendingLocalSync = (value) => { pending.push(value); };
|
|
manager.setAvailablePluginSyncProviders = () => {};
|
|
let collects = 0;
|
|
let collectBarrier: Promise<void> | undefined;
|
|
Object.defineProperty(window, "netcatty", { configurable: true, value: {
|
|
collectPluginSyncSidecars: async () => { collects++; await collectBarrier; return { version: 1, entries: [] }; },
|
|
} });
|
|
const apply = async () => {};
|
|
const config = { hosts: [], keys: [], snippets: [], customGroups: ["fixture"], startupReady: true,
|
|
onApplyPayload: apply, onApplyConvergentPayload: apply };
|
|
function Probe({ enabled = true }: { enabled?: boolean }) { useAutoSync({ ...config, enabled }); return null; }
|
|
let renderer: ReactTestRenderer | undefined;
|
|
const wait = async (ms: number) => { await act(async () => { await new Promise((resolve) => setTimeout(resolve, ms)); }); };
|
|
try {
|
|
store.clear();
|
|
writeMonitorPanels([{ id: "burst", name: "Before", description: "", hostIds: ["h"], view: "list", interval: 5, range: 15, sort: "manual" }]);
|
|
await act(async () => { renderer = create(React.createElement(Probe)); });
|
|
await wait(500);
|
|
const initial = collects;
|
|
assert.ok(initial > 0, "startup still collects a real sync baseline");
|
|
for (let i = 0; i < 10; i++) {
|
|
await act(async () => {
|
|
writeMonitorPanels([{ id: "burst", name: `Edit ${i}`, description: "", hostIds: ["h"], view: "list", interval: 5, range: 15, sort: "manual" }]);
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
});
|
|
}
|
|
assert.equal(collects, initial, "editing must not repeatedly collect all sync data");
|
|
await wait(350);
|
|
assert.equal(collects, initial + 1, "the final edit updates the pending indicator once");
|
|
assert.equal(pending.at(-1), true);
|
|
writeMonitorPanels([]);
|
|
await wait(10);
|
|
await act(async () => { renderer!.update(React.createElement(Probe, { enabled: false })); });
|
|
const disabled = collects;
|
|
await wait(350);
|
|
assert.equal(collects, disabled, "disabling sync cancels the pending collection");
|
|
assert.equal(pending.at(-1), false);
|
|
await act(async () => { renderer!.update(React.createElement(Probe)); });
|
|
await wait(350);
|
|
let releaseCollection!: () => void;
|
|
collectBarrier = new Promise<void>((resolve) => { releaseCollection = resolve; });
|
|
const beforeInflight = collects;
|
|
writeMonitorPanels([{ id: "burst", name: "New edit", description: "", hostIds: ["h"], view: "list", interval: 5, range: 15, sort: "manual" }]);
|
|
await wait(10);
|
|
await wait(350);
|
|
assert.equal(collects, beforeInflight + 1, "the pending collection has started");
|
|
await act(async () => { renderer!.update(React.createElement(Probe, { enabled: false })); });
|
|
await act(async () => { releaseCollection(); });
|
|
assert.equal(pending.at(-1), false, "an old collection cannot re-enable the indicator after disabling sync");
|
|
} finally {
|
|
await act(async () => renderer?.unmount());
|
|
Reflect.deleteProperty(window, "netcatty");
|
|
Object.assign(manager, originals);
|
|
}
|
|
});
|