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
384 lines
14 KiB
JavaScript
384 lines
14 KiB
JavaScript
"use strict";
|
|
|
|
const assert = require("node:assert/strict");
|
|
const { EventEmitter } = require("node:events");
|
|
const test = require("node:test");
|
|
const Module = require("node:module");
|
|
const path = require("node:path");
|
|
|
|
/**
|
|
* broadcastGlobalTransferEvent only loads electron when process.versions.electron
|
|
* is set (avoids install.js downloads in bare Node unit tests).
|
|
*/
|
|
function withElectronVersionStub() {
|
|
const previous = process.versions.electron;
|
|
Object.defineProperty(process.versions, "electron", {
|
|
configurable: true,
|
|
enumerable: true,
|
|
value: previous || "test",
|
|
});
|
|
return () => {
|
|
if (previous === undefined) {
|
|
delete process.versions.electron;
|
|
} else {
|
|
Object.defineProperty(process.versions, "electron", {
|
|
configurable: true,
|
|
enumerable: true,
|
|
value: previous,
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Drive the shipped broadcastGlobalTransferEvent entry point with a stubbed
|
|
* electron BrowserWindow — proves fan-out does not require a panel sender.
|
|
*/
|
|
test("broadcastGlobalTransferEvent fans progress to all live BrowserWindows", () => {
|
|
const sent = [];
|
|
const restoreElectronVersion = withElectronVersionStub();
|
|
const originalLoad = Module._load;
|
|
Module._load = function load(request, parent, isMain) {
|
|
if (request === "electron") {
|
|
return {
|
|
BrowserWindow: {
|
|
getAllWindows() {
|
|
return [
|
|
{
|
|
isDestroyed: () => false,
|
|
webContents: {
|
|
isDestroyed: () => false,
|
|
send(channel, payload) {
|
|
sent.push({ channel, payload });
|
|
},
|
|
},
|
|
},
|
|
{
|
|
isDestroyed: () => true,
|
|
webContents: { isDestroyed: () => false, send() { throw new Error("dead"); } },
|
|
},
|
|
];
|
|
},
|
|
},
|
|
};
|
|
}
|
|
return originalLoad(request, parent, isMain);
|
|
};
|
|
|
|
try {
|
|
// Fresh load so the stub is used.
|
|
const bridgePath = path.join(__dirname, "transferBridge.cjs");
|
|
delete require.cache[require.resolve(bridgePath)];
|
|
const bridge = require(bridgePath);
|
|
assert.equal(typeof bridge.broadcastGlobalTransferEvent, "function");
|
|
|
|
bridge.broadcastGlobalTransferEvent({
|
|
type: "progress",
|
|
transferId: "t-fanout",
|
|
transferred: 50,
|
|
totalBytes: 100,
|
|
speed: 10,
|
|
});
|
|
|
|
assert.equal(sent.length, 1);
|
|
assert.equal(sent[0].channel, "netcatty:sftp:global-transfer");
|
|
assert.equal(sent[0].payload.transferId, "t-fanout");
|
|
assert.equal(sent[0].payload.type, "progress");
|
|
assert.equal(sent[0].payload.transferred, 50);
|
|
} finally {
|
|
Module._load = originalLoad;
|
|
restoreElectronVersion();
|
|
try {
|
|
delete require.cache[require.resolve(path.join(__dirname, "transferBridge.cjs"))];
|
|
} catch { /* ignore */ }
|
|
}
|
|
});
|
|
|
|
test("broadcastGlobalTransferEvent no-ops without transferId", () => {
|
|
const bridge = require("./transferBridge.cjs");
|
|
assert.doesNotThrow(() => bridge.broadcastGlobalTransferEvent({ type: "progress" }));
|
|
assert.doesNotThrow(() => bridge.broadcastGlobalTransferEvent(null));
|
|
});
|
|
|
|
test("same-host cp capability cache follows the live SFTP client, not a recycled session id", async () => {
|
|
const bridge = require("./transferBridge.cjs");
|
|
const sftpClients = new Map();
|
|
bridge.init({ sftpClients });
|
|
|
|
const makeClient = (exitCode, calls) => ({
|
|
client: {
|
|
exec(_command, callback) {
|
|
calls.count += 1;
|
|
const stream = new EventEmitter();
|
|
stream.stderr = new EventEmitter();
|
|
stream.close = () => {};
|
|
callback(null, stream);
|
|
queueMicrotask(() => stream.emit("close", exitCode));
|
|
},
|
|
},
|
|
});
|
|
const firstCalls = { count: 0 };
|
|
const secondCalls = { count: 0 };
|
|
const event = { sender: { send() {} } };
|
|
const payload = {
|
|
sftpId: "recycled-sftp-id",
|
|
sourcePath: "/source",
|
|
targetPath: "/target",
|
|
encoding: "utf-8",
|
|
};
|
|
|
|
sftpClients.set(payload.sftpId, makeClient(127, firstCalls));
|
|
assert.equal((await bridge.sameHostCopyDirectory(event, payload)).success, false);
|
|
assert.equal(firstCalls.count, 1);
|
|
|
|
sftpClients.set(payload.sftpId, makeClient(0, secondCalls));
|
|
assert.equal((await bridge.sameHostCopyDirectory(event, payload)).success, true);
|
|
assert.equal(secondCalls.count, 1, "a replacement connection must probe cp again");
|
|
});
|
|
|
|
test("worker transfer lifecycle epochs survive a reused id and clear after the latest start settles", async () => {
|
|
const bridgePath = path.join(__dirname, "transferBridge.cjs");
|
|
delete require.cache[require.resolve(bridgePath)];
|
|
const bridge = require(bridgePath);
|
|
const handlers = new Map();
|
|
let finishFirst;
|
|
let finishSecond;
|
|
const firstGate = new Promise((resolve) => { finishFirst = resolve; });
|
|
const secondGate = new Promise((resolve) => { finishSecond = resolve; });
|
|
let startCalls = 0;
|
|
|
|
bridge.registerHandlers({
|
|
handle(channel, handler) { handlers.set(channel, handler); },
|
|
}, {
|
|
terminalWorkerManager: {
|
|
request(channel) {
|
|
if (channel === "netcatty:transfer:start") {
|
|
startCalls += 1;
|
|
return startCalls === 1 ? firstGate : secondGate;
|
|
}
|
|
return Promise.resolve({ success: true });
|
|
},
|
|
},
|
|
});
|
|
|
|
const firstStarting = handlers.get("netcatty:transfer:start")(
|
|
{ sender: { id: 1 } },
|
|
{ transferId: "worker-cache-lifetime", skipAdmission: true },
|
|
);
|
|
const secondStarting = handlers.get("netcatty:transfer:start")(
|
|
{ sender: { id: 1 } },
|
|
{ transferId: "worker-cache-lifetime", skipAdmission: true },
|
|
);
|
|
assert.equal(bridge._getWorkerTransferLifecycleEpochCountForTests(), 1);
|
|
|
|
finishFirst({ transferId: "worker-cache-lifetime" });
|
|
await firstStarting;
|
|
assert.equal(
|
|
bridge._getWorkerTransferLifecycleEpochCountForTests(),
|
|
1,
|
|
"an older completion must not clear the newer start's lifecycle state",
|
|
);
|
|
|
|
finishSecond({ transferId: "worker-cache-lifetime" });
|
|
await secondStarting;
|
|
assert.equal(bridge._getWorkerTransferLifecycleEpochCountForTests(), 0);
|
|
});
|
|
|
|
test("worker transfer lifecycle epoch clears after a synchronous start failure", () => {
|
|
const bridgePath = path.join(__dirname, "transferBridge.cjs");
|
|
delete require.cache[require.resolve(bridgePath)];
|
|
const bridge = require(bridgePath);
|
|
const handlers = new Map();
|
|
bridge.registerHandlers({
|
|
handle(channel, handler) { handlers.set(channel, handler); },
|
|
}, {
|
|
terminalWorkerManager: {
|
|
request(channel) {
|
|
if (channel === "netcatty:transfer:start") throw new Error("worker unavailable");
|
|
return Promise.resolve({ success: true });
|
|
},
|
|
},
|
|
});
|
|
|
|
assert.throws(() => handlers.get("netcatty:transfer:start")(
|
|
{ sender: { id: 1 } },
|
|
{ transferId: "worker-cache-sync-failure", skipAdmission: true },
|
|
), /worker unavailable/);
|
|
assert.equal(bridge._getWorkerTransferLifecycleEpochCountForTests(), 0);
|
|
});
|
|
|
|
test("worker-backed pause and resume fan authoritative lifecycle to every window", async () => {
|
|
const sent = [];
|
|
const restoreElectronVersion = withElectronVersionStub();
|
|
const originalLoad = Module._load;
|
|
Module._load = function load(request, parent, isMain) {
|
|
if (request === "electron") {
|
|
return {
|
|
BrowserWindow: {
|
|
getAllWindows: () => [{
|
|
isDestroyed: () => false,
|
|
webContents: {
|
|
isDestroyed: () => false,
|
|
send(channel, payload) { sent.push({ channel, payload }); },
|
|
},
|
|
}],
|
|
},
|
|
};
|
|
}
|
|
return originalLoad(request, parent, isMain);
|
|
};
|
|
|
|
const bridgePath = path.join(__dirname, "transferBridge.cjs");
|
|
try {
|
|
delete require.cache[require.resolve(bridgePath)];
|
|
const bridge = require(bridgePath);
|
|
const handlers = new Map();
|
|
let finishStart;
|
|
const startGate = new Promise((resolve) => { finishStart = resolve; });
|
|
bridge.registerHandlers({
|
|
handle(channel, handler) { handlers.set(channel, handler); },
|
|
}, {
|
|
terminalWorkerManager: {
|
|
async request(channel) {
|
|
if (channel === "netcatty:transfer:start") return startGate;
|
|
if (channel === "netcatty:transfer:pause") {
|
|
return { success: true, checkpointBytes: 50, resumeStage: "upload" };
|
|
}
|
|
return { success: true };
|
|
},
|
|
},
|
|
});
|
|
|
|
const starting = handlers.get("netcatty:transfer:start")(
|
|
{ sender: { id: 1 } },
|
|
{ transferId: "worker-transfer", skipAdmission: true },
|
|
);
|
|
await handlers.get("netcatty:transfer:pause")(
|
|
{ sender: { id: 1 } },
|
|
{ transferId: "worker-transfer" },
|
|
);
|
|
await handlers.get("netcatty:transfer:resume")(
|
|
{ sender: { id: 1 } },
|
|
{ transferId: "worker-transfer" },
|
|
);
|
|
|
|
const lifecycle = sent
|
|
.filter((entry) => entry.channel === "netcatty:sftp:global-transfer")
|
|
.map((entry) => entry.payload);
|
|
assert.deepEqual(lifecycle, [
|
|
{
|
|
type: "pausing",
|
|
transferId: "worker-transfer",
|
|
lifecycleEpoch: 1,
|
|
lifecycleState: "pausing",
|
|
},
|
|
{
|
|
type: "paused",
|
|
transferId: "worker-transfer",
|
|
checkpointBytes: 50,
|
|
resumeStage: "upload",
|
|
downloadCheckpointBytes: undefined,
|
|
uploadCheckpointBytes: undefined,
|
|
sourceFingerprint: undefined,
|
|
lifecycleEpoch: 1,
|
|
lifecycleState: "paused",
|
|
},
|
|
{
|
|
type: "resumed",
|
|
transferId: "worker-transfer",
|
|
lifecycleEpoch: 2,
|
|
lifecycleState: "transferring",
|
|
},
|
|
]);
|
|
finishStart({ transferId: "worker-transfer" });
|
|
await starting;
|
|
assert.equal(bridge._getWorkerTransferLifecycleEpochCountForTests(), 0);
|
|
} finally {
|
|
Module._load = originalLoad;
|
|
restoreElectronVersion();
|
|
delete require.cache[require.resolve(bridgePath)];
|
|
}
|
|
});
|
|
|
|
for (const latestAction of ["pause", "resume", "cancel"]) {
|
|
test(`stale worker pause retains latest ${latestAction} after that newer request has settled`, async (t) => {
|
|
const bridgePath = require.resolve("./transferBridge.cjs");
|
|
delete require.cache[bridgePath];
|
|
t.after(() => { delete require.cache[bridgePath]; });
|
|
const bridge = require(bridgePath);
|
|
const handlers = new Map();
|
|
let finishFirst;
|
|
const firstGate = new Promise((resolve) => { finishFirst = resolve; });
|
|
let requests = 0;
|
|
bridge.registerHandlers({ handle: (channel, fn) => handlers.set(channel, fn) }, {
|
|
terminalWorkerManager: { request: async () => ++requests === 1 ? firstGate : { success: true } },
|
|
});
|
|
const payload = { transferId: `worker-last-action-${latestAction}` };
|
|
const first = handlers.get("netcatty:transfer:pause")(null, payload);
|
|
await handlers.get(`netcatty:transfer:${latestAction}`)(null, payload);
|
|
finishFirst({ success: true });
|
|
const stale = await first;
|
|
assert.equal(stale.superseded, true);
|
|
assert.equal(stale.supersededBy, latestAction, "completed newer request must retain intent for older pending replies");
|
|
});
|
|
}
|
|
|
|
for (const outcome of ["resume-success", "pause-success", "pause-failure", "pause-error", "resume-error"]) {
|
|
test(`worker ${outcome} arriving after a newer pause cannot broadcast resumed`, async (t) => {
|
|
const sent = [];
|
|
const restore = withElectronVersionStub();
|
|
const originalLoad = Module._load;
|
|
Module._load = function (request, parent, isMain) {
|
|
if (request === "electron") return { BrowserWindow: { getAllWindows: () => [{
|
|
isDestroyed: () => false,
|
|
webContents: { isDestroyed: () => false, send: (_channel, payload) => sent.push(payload) },
|
|
}] } };
|
|
return originalLoad(request, parent, isMain);
|
|
};
|
|
const bridgePath = require.resolve("./transferBridge.cjs");
|
|
delete require.cache[bridgePath];
|
|
t.after(() => { Module._load = originalLoad; restore(); delete require.cache[bridgePath]; });
|
|
const bridge = require(bridgePath);
|
|
const handlers = new Map();
|
|
let resolveFirst;
|
|
let rejectFirst;
|
|
const firstGate = new Promise((resolve, reject) => { resolveFirst = resolve; rejectFirst = reject; });
|
|
let requests = 0;
|
|
bridge.registerHandlers({ handle: (channel, fn) => handlers.set(channel, fn) }, {
|
|
terminalWorkerManager: { request: async () => ++requests === 1 ? firstGate : { success: true } },
|
|
});
|
|
const payload = { transferId: `worker-order-${outcome}` };
|
|
const channel = outcome.startsWith("resume") ? "resume" : "pause";
|
|
const first = handlers.get(`netcatty:transfer:${channel}`)(null, payload).catch(() => null);
|
|
await handlers.get("netcatty:transfer:pause")(null, payload);
|
|
const afterPause = sent.length;
|
|
if (outcome.endsWith("error")) rejectFirst(new Error("channel closed"));
|
|
else resolveFirst({ success: outcome.endsWith("success"), reason: "pause unavailable" });
|
|
assert.deepEqual(await first, { success: false, superseded: true, supersededBy: "pause" });
|
|
assert.equal(sent.slice(afterPause).some((event) => event.type === "resumed"), false);
|
|
assert.equal(sent.at(-1).type, "paused");
|
|
});
|
|
}
|
|
|
|
for (const action of ["pause", "resume"]) {
|
|
test(`superseded result follows rollback after latest ${action} fails`, async (t) => {
|
|
const bridgePath = require.resolve("./transferBridge.cjs");
|
|
delete require.cache[bridgePath];
|
|
t.after(() => { delete require.cache[bridgePath]; });
|
|
const bridge = require(bridgePath);
|
|
const handlers = new Map();
|
|
let finish;
|
|
let calls = 0;
|
|
bridge.registerHandlers({ handle: (channel, fn) => handlers.set(channel, fn) }, {
|
|
terminalWorkerManager: { request: () => ++calls === 1
|
|
? new Promise(resolve => { finish = resolve; })
|
|
: Promise.resolve({ success: false, reason: "unavailable" }) },
|
|
});
|
|
const payload = { transferId: `rollback-${action}` };
|
|
const older = handlers.get("netcatty:transfer:pause")(null, payload);
|
|
await handlers.get(`netcatty:transfer:${action}`)(null, payload);
|
|
finish({ success: true });
|
|
assert.equal((await older).supersededBy, action === "pause" ? "resume" : "pause");
|
|
});
|
|
}
|