[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
422
application/state/usePortForwardingAutoStart.test.ts
Normal file
422
application/state/usePortForwardingAutoStart.test.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
runPortForwardingAutoStart,
|
||||
subscribeToPortForwardingNetworkRecovery,
|
||||
getAutoStartRuleBlockReason,
|
||||
isAutoStartProxyReady,
|
||||
isPortForwardingAutoStartEnabled,
|
||||
} from "./usePortForwardingAutoStart.ts";
|
||||
import type { GroupConfig, Host, PortForwardingRule, ProxyProfile } from "../../domain/models.ts";
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys.ts";
|
||||
import {
|
||||
getActiveConnection,
|
||||
setReconnectCallback,
|
||||
startPortForward,
|
||||
stopPortForward,
|
||||
stopAndCleanupRuleAndWait,
|
||||
} from "../../infrastructure/services/portForwardingService.ts";
|
||||
|
||||
const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const proxyProfile = (id: string): ProxyProfile => ({
|
||||
id,
|
||||
label: "Proxy",
|
||||
config: { type: "http", host: "proxy.example.com", port: 3128 },
|
||||
createdAt: 1,
|
||||
});
|
||||
|
||||
const rule = (overrides: Partial<PortForwardingRule> = {}): PortForwardingRule => ({
|
||||
id: "rule-1",
|
||||
label: "Rule",
|
||||
type: "local",
|
||||
localPort: 8080,
|
||||
bindAddress: "127.0.0.1",
|
||||
remoteHost: "127.0.0.1",
|
||||
remotePort: 80,
|
||||
hostId: "host-1",
|
||||
autoStart: true,
|
||||
status: "inactive",
|
||||
createdAt: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const deferred = <T>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
const installStorage = () => {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
const backing = new Map<string, string>();
|
||||
const storage: Storage = {
|
||||
get length() { return backing.size; },
|
||||
clear() { backing.clear(); },
|
||||
getItem(key) { return backing.get(key) ?? null; },
|
||||
key(index) { return Array.from(backing.keys())[index] ?? null; },
|
||||
removeItem(key) { backing.delete(key); },
|
||||
setItem(key, value) { backing.set(key, value); },
|
||||
};
|
||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: storage });
|
||||
return {
|
||||
storage,
|
||||
restore() {
|
||||
if (previous) Object.defineProperty(globalThis, "localStorage", previous);
|
||||
else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const autoStartOptions = (hosts: Host[]) => ({
|
||||
hosts,
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groupConfigs: [],
|
||||
knownHosts: [],
|
||||
isHostAuthReady: () => true,
|
||||
resolveEffectiveHost: (currentHost: Host) => currentHost,
|
||||
updateStoredRuleStatus: () => undefined,
|
||||
});
|
||||
|
||||
test("isAutoStartProxyReady waits when a host saved proxy is unresolved", () => {
|
||||
assert.equal(
|
||||
isAutoStartProxyReady(
|
||||
host({ proxyProfileId: "missing-proxy" }),
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("isAutoStartProxyReady waits when a missing host proxy has a group fallback", () => {
|
||||
const groupConfigs: GroupConfig[] = [{ path: "prod", proxyProfileId: "group-proxy" }];
|
||||
const currentHost = host({ group: "prod", proxyProfileId: "missing-proxy" });
|
||||
|
||||
assert.equal(
|
||||
isAutoStartProxyReady(
|
||||
currentHost,
|
||||
[currentHost],
|
||||
[proxyProfile("group-proxy")],
|
||||
groupConfigs,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("isAutoStartProxyReady waits when a group saved proxy is unresolved", () => {
|
||||
const groupConfigs: GroupConfig[] = [{ path: "prod", proxyProfileId: "missing-proxy" }];
|
||||
const currentHost = host({ group: "prod" });
|
||||
|
||||
assert.equal(
|
||||
isAutoStartProxyReady(
|
||||
currentHost,
|
||||
[currentHost],
|
||||
[],
|
||||
groupConfigs,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("isAutoStartProxyReady checks group-inherited jump hosts", () => {
|
||||
const currentHost = host({ group: "prod" });
|
||||
const jumpHost = host({ id: "jump-1", proxyProfileId: "missing-proxy" });
|
||||
|
||||
assert.equal(
|
||||
isAutoStartProxyReady(
|
||||
currentHost,
|
||||
[currentHost, jumpHost],
|
||||
[],
|
||||
[{ path: "prod", hostChain: { hostIds: ["jump-1"] } }],
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("getAutoStartRuleBlockReason only blocks the affected rule", () => {
|
||||
const goodHost = host();
|
||||
const badHost = host({ id: "host-2", proxyProfileId: "missing-proxy" });
|
||||
const hosts = [goodHost, badHost];
|
||||
const isHostAuthReady = () => true;
|
||||
|
||||
assert.equal(
|
||||
getAutoStartRuleBlockReason(rule({ id: "good", hostId: "host-1" }), hosts, [], [], isHostAuthReady),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
getAutoStartRuleBlockReason(rule({ id: "bad", hostId: "host-2" }), hosts, [], [], isHostAuthReady),
|
||||
"Proxy or jump host configuration is not ready",
|
||||
);
|
||||
});
|
||||
|
||||
test("getAutoStartRuleBlockReason marks rules without a host", () => {
|
||||
assert.equal(
|
||||
getAutoStartRuleBlockReason(rule({ hostId: undefined }), [], [], [], () => true),
|
||||
"Rule host is not configured",
|
||||
);
|
||||
});
|
||||
|
||||
test("reconnect eligibility follows the current auto-start setting", () => {
|
||||
assert.equal(isPortForwardingAutoStartEnabled([rule({ autoStart: true })], "rule-1"), true);
|
||||
assert.equal(isPortForwardingAutoStartEnabled([rule({ autoStart: false })], "rule-1"), false);
|
||||
assert.equal(isPortForwardingAutoStartEnabled([], "rule-1"), false);
|
||||
});
|
||||
|
||||
test("network recovery starts an exhausted auto-start rule through the real service path", async (t) => {
|
||||
const env = installStorage();
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
let onlineListener: (() => void) | undefined;
|
||||
const startedRuleIds: string[] = [];
|
||||
const exhaustedStartResult = deferred<{ success: boolean; error: string }>();
|
||||
let preparingExhaustedRule = true;
|
||||
const backendSnapshot = deferred<[]>();
|
||||
let snapshotCalls = 0;
|
||||
const target = {
|
||||
addEventListener(type: string, listener: () => void) {
|
||||
if (type === "online") onlineListener = listener;
|
||||
},
|
||||
removeEventListener(type: string, listener: () => void) {
|
||||
if (type === "online" && onlineListener === listener) onlineListener = undefined;
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {
|
||||
netcatty: {
|
||||
listPortForwards: async () => {
|
||||
snapshotCalls++;
|
||||
return backendSnapshot.promise;
|
||||
},
|
||||
startPortForward: async ({ ruleId }: { ruleId: string }) => {
|
||||
startedRuleIds.push(ruleId);
|
||||
if (ruleId === "exhausted" && preparingExhaustedRule) {
|
||||
return exhaustedStartResult.promise;
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
onPortForwardStatus: () => () => undefined,
|
||||
stopPortForwardByRuleId: async () => ({ stopped: 1, failed: 0, errors: [] }),
|
||||
},
|
||||
},
|
||||
});
|
||||
t.after(async () => {
|
||||
await Promise.all([
|
||||
stopAndCleanupRuleAndWait("exhausted"),
|
||||
stopAndCleanupRuleAndWait("already-active"),
|
||||
]);
|
||||
setReconnectCallback(null);
|
||||
env.restore();
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
});
|
||||
|
||||
const rules = [
|
||||
rule({ id: "exhausted", autoStart: true }),
|
||||
rule({ id: "already-active", autoStart: true }),
|
||||
rule({ id: "manually-stopped", autoStart: true, status: "inactive" }),
|
||||
rule({ id: "manual", autoStart: false }),
|
||||
];
|
||||
env.storage.setItem(STORAGE_KEY_PORT_FORWARDING, JSON.stringify(rules));
|
||||
setReconnectCallback(async () => ({ success: true }));
|
||||
const exhaustedStart = startPortForward(
|
||||
rules[0]!,
|
||||
host(),
|
||||
[host()],
|
||||
[],
|
||||
[],
|
||||
() => undefined,
|
||||
true,
|
||||
);
|
||||
const exhaustedConnection = getActiveConnection("exhausted");
|
||||
assert.ok(exhaustedConnection);
|
||||
exhaustedConnection.reconnectAttempts = 5;
|
||||
exhaustedStartResult.resolve({ success: false, error: "offline socket" });
|
||||
await exhaustedStart;
|
||||
preparingExhaustedRule = false;
|
||||
|
||||
await startPortForward(
|
||||
rules[1]!,
|
||||
host(),
|
||||
[host()],
|
||||
[],
|
||||
[],
|
||||
() => undefined,
|
||||
true,
|
||||
);
|
||||
startedRuleIds.length = 0;
|
||||
|
||||
const unsubscribe = subscribeToPortForwardingNetworkRecovery(target, (recoveryRuleIds) =>
|
||||
runPortForwardingAutoStart({
|
||||
...autoStartOptions([host()]),
|
||||
recoveryRuleIds,
|
||||
}),
|
||||
);
|
||||
|
||||
onlineListener?.();
|
||||
onlineListener?.();
|
||||
assert.equal(snapshotCalls, 1);
|
||||
backendSnapshot.resolve([]);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(startedRuleIds, ["exhausted"]);
|
||||
|
||||
unsubscribe();
|
||||
assert.equal(onlineListener, undefined);
|
||||
});
|
||||
|
||||
test("network recovery refreshes retries for a connecting final attempt", async (t) => {
|
||||
const env = installStorage();
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const startResult = deferred<{ success: boolean; error: string }>();
|
||||
let onlineListener: (() => void) | undefined;
|
||||
const target = {
|
||||
addEventListener(type: string, listener: () => void) {
|
||||
if (type === "online") onlineListener = listener;
|
||||
},
|
||||
removeEventListener(type: string, listener: () => void) {
|
||||
if (type === "online" && onlineListener === listener) onlineListener = undefined;
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {
|
||||
netcatty: {
|
||||
listPortForwards: async () => [],
|
||||
startPortForward: async () => startResult.promise,
|
||||
onPortForwardStatus: () => () => undefined,
|
||||
stopPortForwardByRuleId: async () => ({ stopped: 1, failed: 0, errors: [] }),
|
||||
},
|
||||
},
|
||||
});
|
||||
const retryingRule = rule({ id: "recovering-final-attempt", autoStart: true });
|
||||
env.storage.setItem(STORAGE_KEY_PORT_FORWARDING, JSON.stringify([retryingRule]));
|
||||
setReconnectCallback(async () => ({ success: true }));
|
||||
const unsubscribe = subscribeToPortForwardingNetworkRecovery(target, (recoveryRuleIds) =>
|
||||
runPortForwardingAutoStart({
|
||||
...autoStartOptions([host()]),
|
||||
recoveryRuleIds,
|
||||
}),
|
||||
);
|
||||
t.after(async () => {
|
||||
unsubscribe();
|
||||
setReconnectCallback(null);
|
||||
await stopAndCleanupRuleAndWait(retryingRule.id);
|
||||
env.restore();
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
});
|
||||
|
||||
const pendingStart = startPortForward(
|
||||
retryingRule,
|
||||
host(),
|
||||
[host()],
|
||||
[],
|
||||
[],
|
||||
() => undefined,
|
||||
true,
|
||||
);
|
||||
const connection = getActiveConnection(retryingRule.id);
|
||||
assert.ok(connection);
|
||||
connection.reconnectAttempts = 5;
|
||||
|
||||
onlineListener?.();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.equal(connection.reconnectAttempts, 0);
|
||||
|
||||
startResult.resolve({ success: false, error: "offline socket" });
|
||||
await pendingStart;
|
||||
assert.equal(connection.reconnectAttempts, 1);
|
||||
assert.ok(connection.reconnectTimerCallback);
|
||||
});
|
||||
|
||||
test("manual stop cancels an exhausted recovery while backend sync is pending", async (t) => {
|
||||
const env = installStorage();
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const exhaustedStartResult = deferred<{ success: boolean; error: string }>();
|
||||
const backendSnapshot = deferred<[]>();
|
||||
const stopResult = deferred<{ stopped: number; failed: number; errors: string[] }>();
|
||||
const startedRuleIds: string[] = [];
|
||||
let preparingExhaustedRule = true;
|
||||
let onlineListener: (() => void) | undefined;
|
||||
const target = {
|
||||
addEventListener(type: string, listener: () => void) {
|
||||
if (type === "online") onlineListener = listener;
|
||||
},
|
||||
removeEventListener(type: string, listener: () => void) {
|
||||
if (type === "online" && onlineListener === listener) onlineListener = undefined;
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {
|
||||
netcatty: {
|
||||
listPortForwards: async () => backendSnapshot.promise,
|
||||
startPortForward: async ({ ruleId }: { ruleId: string }) => {
|
||||
startedRuleIds.push(ruleId);
|
||||
if (preparingExhaustedRule) return exhaustedStartResult.promise;
|
||||
return { success: true };
|
||||
},
|
||||
onPortForwardStatus: () => () => undefined,
|
||||
stopPortForwardByRuleId: async () => stopResult.promise,
|
||||
},
|
||||
},
|
||||
});
|
||||
const exhaustedRule = rule({ id: "exhausted-then-stopped", autoStart: true });
|
||||
env.storage.setItem(STORAGE_KEY_PORT_FORWARDING, JSON.stringify([exhaustedRule]));
|
||||
setReconnectCallback(async () => ({ success: true }));
|
||||
const unsubscribe = subscribeToPortForwardingNetworkRecovery(target, (recoveryRuleIds) =>
|
||||
runPortForwardingAutoStart({
|
||||
...autoStartOptions([host()]),
|
||||
recoveryRuleIds,
|
||||
}),
|
||||
);
|
||||
t.after(async () => {
|
||||
unsubscribe();
|
||||
setReconnectCallback(null);
|
||||
await stopAndCleanupRuleAndWait(exhaustedRule.id);
|
||||
env.restore();
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
});
|
||||
|
||||
const exhaustedStart = startPortForward(
|
||||
exhaustedRule,
|
||||
host(),
|
||||
[host()],
|
||||
[],
|
||||
[],
|
||||
() => undefined,
|
||||
true,
|
||||
);
|
||||
const connection = getActiveConnection(exhaustedRule.id);
|
||||
assert.ok(connection);
|
||||
connection.reconnectAttempts = 5;
|
||||
exhaustedStartResult.resolve({ success: false, error: "offline socket" });
|
||||
await exhaustedStart;
|
||||
preparingExhaustedRule = false;
|
||||
startedRuleIds.length = 0;
|
||||
|
||||
onlineListener?.();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const manualStop = stopPortForward(exhaustedRule.id, () => undefined);
|
||||
backendSnapshot.resolve([]);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(startedRuleIds, []);
|
||||
stopResult.resolve({ stopped: 1, failed: 0, errors: [] });
|
||||
await manualStop;
|
||||
});
|
||||
Reference in New Issue
Block a user