mirror of
https://github.com/proxysoul/Empryo.git
synced 2026-11-07 06:25:57 -10:00
## Summary Fixes **image paste via Ctrl+V on Windows**, which was silently failing or requiring several attempts before working. The problem had three distinct root causes whose symptoms overlapped from the user's perspective; this PR addresses all three. ## Symptoms (before) - Ctrl+V with an image on the clipboard did nothing visible - After 2-3 seconds, the image would appear — or it wouldn't, with no error shown - In many cases, the user had to press Ctrl+V 5-6 times for the image to finally load - When the user pasted text, the following Ctrl+V would ignore the image for up to 3s ## Root causes ### 1. PowerShell refused to save the image `readImageWindows()` was passing the temp file path as a positional `-OutFile` argument after `-Command`. In PowerShell 5.1, the `param(Mandatory)` binder fails silently — `MissingMandatoryParameter` goes to stderr that no one reads, and the function resolves with `null` — making the paste look broken with no message. **Fix:** the temp file path is now interpolated into the PowerShell script body (with single-quote escaping) instead of being passed via argv. ### 2. PowerShell cold-start: 2-3s per paste Each Ctrl+V spawned a new `powershell.exe`. The PowerShell host startup + the JIT of `Add-Type -AssemblyName System.Windows.Forms` cost 2-3s on machines where the NGen cache wasn't warm. The pre-warm at boot helped partially, but couldn't pre-pay the startup of the process itself. **Fix:** replaced with a **persistent daemon**. A single `powershell.exe` stays alive for the entire session, listening for `READ\n` commands on stdin and replying with `OK <base64>\nEND\n` or `NO\nEND\n` on stdout. Startup happens once in the background at boot; every subsequent paste takes ~50-200ms. The protocol uses `END` as an end-of-response sentinel. The base64 alphabet is `[A-Za-z0-9+/=]`, so `END` never collides with payload bytes. On death/exit/timeout, the daemon respawns transparently. ### 3. Shared mutex between keydown and paste A single `imageLoadingRef` was shared between the Ctrl+V keydown handler and the paste event handler. When the user pasted text, the paste handler set the flag and the keydown handler ignored Ctrl+V for up to 3s. **Fix:** two independent mutexes — `keydownImageLoadingRef` and `pasteImageLoadingRef`. Both handlers run in parallel without blocking each other. ### 4. No visual feedback during the read The paste took 2-3s with no indicator. The user assumed it was broken and pressed Ctrl+V multiple times, each attempt bailing on the mutex. **Fix:** new `clipboardReading` state that shows `· reading image…` in the right corner of the input box during the read. The textarea also shrinks its width to accommodate the hint without clipping text. ## Files changed | File | What changed | | --- | --- | | `src/core/platform/clipboard.ts` | Rewritten: persistent daemon via `spawn()` + stdin/stdout protocol; in-flight dedup; tmpFile inlined into the script; exports `startClipboardDaemon()` instead of `prewarmWindowsClipboard()` | | `src/boot.tsx` | `prewarmAllModels()` is now followed by `startClipboardDaemon()` to bring up the daemon in the background during boot | | `src/components/chat/InputBox.tsx` | `imageLoadingRef` replaced by `keydownImageLoadingRef` + `pasteImageLoadingRef`; added `clipboardReading` state with `· reading image…` hint; paste handler also probes for image (not just the keydown); reorganized `hintWidth`/`textareaWidth` to depend on `showAutocomplete` without using it before its declaration | | `tests/platform-windows.test.ts` | `execFile` mock replaced by `spawn` mock with `PassThrough` streams; 3 new tests: in-flight reuse, waits for READY, respawn after exit | ## How it works now 1. **Boot:** the daemon comes up in the background. In ~500-1500ms it emits `READY` on stdout. The boot splash continues normally. 2. **Ctrl+V:** the keydown handler detects Ctrl+V (or Alt+V as fallback). It calls `readClipboardImage()`. The daemon receives `READ\n` on stdin and replies with `OK <base64>\nEND\n` in ~50-200ms. 3. **Indicator:** while the read is in flight, the input box shows `· reading image…` on the right. 4. **Image arrives:** the handler inserts `[image-N]` into the textarea and stores the base64 in `pendingImages`. The submit includes the image on the next send. 5. **Spam Ctrl+V:** extra attempts bail on the mutex immediately. The indicator stays visible and the first attempt's image lands normally. ## Tests - **typecheck:** clean - **biome lint:** clean - **clipboard tests:** 37 pass, 0 fail, 1 skip (POSIX) - `contract: no -OutFile/WriteAllBytes path in source` — guard against regression of bug 1 - `in-flight reuse: concurrent calls share one read` — two concurrent calls result in 1 single `READ` on stdin - `waits for READY before serving reads` — start() does not resolve until PS emits READY (no race with Add-Type) - `respawn after process exit` — proc dying mid-session → next read creates a new spawn transparently ## Manual testing performed - Cold PC start, first Ctrl+V: image appears in ~1-2s (time for the daemon to come up once) - Subsequent Ctrl+V: image appears almost instantly - Spam of 5-6 rapid Ctrl+V: `· reading image…` indicator visible throughout, the first attempt's image lands without failure - Killing `powershell.exe` in Task Manager mid-session: next Ctrl+V continues to work (daemon respawns) - Text + image scenario: both arrive correctly at submit ## Notes - macOS and Linux are not affected — `startClipboardDaemon()` is a no-op outside `IS_WIN`, and the existing paths (`readImageDarwin`, `readImageLinux`) remain unchanged. - The daemon is tolerant of PS being unavailable: if `powershell.exe` cannot be found, `readClipboardImage()` simply returns `null` (paste with no image, current behaviour). - The `powershell.exe` process stays in the background consuming ~60-100MB of RAM. Accepted trade-off: better UX at the cost of footprint, opt-in via boot.
614 lines
22 KiB
TypeScript
614 lines
22 KiB
TypeScript
/**
|
|
* Cross-platform shim sanity tests for src/core/platform/.
|
|
*
|
|
* These tests run on every platform — POSIX hosts validate the POSIX branches,
|
|
* Windows hosts validate the win32 branches. The DPAPI roundtrip is gated
|
|
* behind process.platform === "win32" because crypt32.dll is Windows-only.
|
|
*/
|
|
|
|
import { describe, expect, test } from "bun:test";
|
|
import {
|
|
CMD_EXT,
|
|
commandExists,
|
|
configDir,
|
|
dataDir,
|
|
EXE,
|
|
findOnPath,
|
|
ghosttyDisabled,
|
|
IS_DARWIN,
|
|
IS_LINUX,
|
|
IS_WIN,
|
|
masonBinDir,
|
|
shellInvocation,
|
|
tmpDir,
|
|
userDataDir,
|
|
} from "../src/core/platform/index.js";
|
|
import { makeIpcSocketPath, shouldCleanupSocketFile } from "../src/core/platform/socket.js";
|
|
import {
|
|
extractArchive,
|
|
extractTarGz,
|
|
extractZip,
|
|
} from "../src/core/platform/archive.js";
|
|
|
|
describe("platform shim: identity", () => {
|
|
test("exactly one platform flag is true", () => {
|
|
const flags = [IS_WIN, IS_DARWIN, IS_LINUX].filter(Boolean);
|
|
expect(flags.length).toBe(1);
|
|
});
|
|
|
|
test("EXE suffix matches platform", () => {
|
|
expect(EXE).toBe(IS_WIN ? ".exe" : "");
|
|
});
|
|
|
|
test("CMD_EXT suffix matches platform", () => {
|
|
expect(CMD_EXT).toBe(IS_WIN ? ".cmd" : "");
|
|
});
|
|
});
|
|
|
|
describe("platform shim: paths", () => {
|
|
test("tmpDir returns a non-empty string", () => {
|
|
expect(tmpDir().length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("configDir is platform-appropriate", () => {
|
|
const dir = configDir();
|
|
if (IS_WIN) {
|
|
// %APPDATA%\SoulForge
|
|
expect(dir.toLowerCase()).toContain("appdata");
|
|
expect(dir).toContain("SoulForge");
|
|
} else {
|
|
expect(dir.endsWith(".soulforge")).toBe(true);
|
|
}
|
|
});
|
|
|
|
test("dataDir is platform-appropriate", () => {
|
|
const dir = dataDir();
|
|
if (IS_WIN) {
|
|
// %LOCALAPPDATA%\SoulForge
|
|
expect(dir.toLowerCase()).toMatch(/local|appdata/);
|
|
expect(dir).toContain("SoulForge");
|
|
} else {
|
|
expect(dir.endsWith(".soulforge")).toBe(true);
|
|
}
|
|
});
|
|
|
|
test("masonBinDir is platform-appropriate", () => {
|
|
const dir = masonBinDir();
|
|
if (IS_WIN) {
|
|
expect(dir).toContain("nvim-data");
|
|
expect(dir).toContain("mason");
|
|
} else {
|
|
expect(dir).toContain(".local/share/nvim/mason/bin");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("platform shim: shell", () => {
|
|
test("shellInvocation returns the right shell", () => {
|
|
const { cmd, flag } = shellInvocation();
|
|
if (IS_WIN) {
|
|
expect(cmd.toLowerCase()).toContain("cmd");
|
|
expect(flag).toContain("/c");
|
|
} else {
|
|
expect(cmd).toBe("sh");
|
|
expect(flag).toBe("-c");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("platform shim: socket paths", () => {
|
|
test("makeIpcSocketPath produces a named pipe on Windows", () => {
|
|
const p = makeIpcSocketPath("sf-test-12345");
|
|
if (IS_WIN) {
|
|
expect(p.startsWith("\\\\.\\pipe\\")).toBe(true);
|
|
expect(p).toContain("sf-test-12345");
|
|
} else {
|
|
expect(p.endsWith(".sock")).toBe(true);
|
|
expect(p).toContain("sf-test-12345");
|
|
}
|
|
});
|
|
|
|
test("makeIpcSocketPath sanitises label", () => {
|
|
const p = makeIpcSocketPath("sf/dangerous\\label");
|
|
// Path separators must not survive — they'd let an attacker escape the
|
|
// pipe/UDS namespace.
|
|
if (IS_WIN) {
|
|
const labelPart = p.slice("\\\\.\\pipe\\".length);
|
|
expect(labelPart.includes("/")).toBe(false);
|
|
expect(labelPart.includes("\\")).toBe(false);
|
|
} else {
|
|
// POSIX: only the tail (after tmpdir) should be sanitised.
|
|
const tail = p.split("/").pop() ?? "";
|
|
expect(tail.includes("\\")).toBe(false);
|
|
}
|
|
});
|
|
|
|
test("shouldCleanupSocketFile matches platform", () => {
|
|
expect(shouldCleanupSocketFile()).toBe(!IS_WIN);
|
|
});
|
|
});
|
|
|
|
describe("platform shim: ghosttyDisabled", () => {
|
|
test("false on non-Windows", () => {
|
|
if (IS_WIN) return;
|
|
expect(ghosttyDisabled()).toBe(false);
|
|
});
|
|
|
|
test("true on Windows ARM64 regardless of env opt-in", () => {
|
|
if (!IS_WIN || process.arch !== "arm64") return;
|
|
const orig = process.env.SOULFORGE_ENABLE_GHOSTTY;
|
|
process.env.SOULFORGE_ENABLE_GHOSTTY = "1";
|
|
try {
|
|
expect(ghosttyDisabled()).toBe(true);
|
|
} finally {
|
|
if (orig !== undefined) process.env.SOULFORGE_ENABLE_GHOSTTY = orig;
|
|
else delete process.env.SOULFORGE_ENABLE_GHOSTTY;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("platform shim: PATH probes", () => {
|
|
test("commandExists returns true for a baseline tool", () => {
|
|
const probe = IS_WIN ? ["cmd", "where"] : ["sh", "ls"];
|
|
const found = probe.some((c) => commandExists(c));
|
|
expect(found).toBe(true);
|
|
});
|
|
|
|
test("findOnPath returns null for an impossible binary", () => {
|
|
expect(findOnPath("definitely-not-a-real-binary-xyzzy-123")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("platform shim: HOME-honouring paths", () => {
|
|
test("configDir respects process.env.HOME on POSIX", () => {
|
|
if (IS_WIN) return;
|
|
const orig = process.env.HOME;
|
|
process.env.HOME = "/tmp/sf-test-home-config";
|
|
try {
|
|
expect(configDir()).toBe("/tmp/sf-test-home-config/.soulforge");
|
|
} finally {
|
|
if (orig !== undefined) process.env.HOME = orig;
|
|
else delete process.env.HOME;
|
|
}
|
|
});
|
|
|
|
test("dataDir respects process.env.HOME on POSIX", () => {
|
|
if (IS_WIN) return;
|
|
const orig = process.env.HOME;
|
|
process.env.HOME = "/tmp/sf-test-home-data";
|
|
try {
|
|
expect(dataDir()).toBe("/tmp/sf-test-home-data/.soulforge");
|
|
} finally {
|
|
if (orig !== undefined) process.env.HOME = orig;
|
|
else delete process.env.HOME;
|
|
}
|
|
});
|
|
|
|
test("userDataDir respects process.env.HOME on POSIX", () => {
|
|
if (IS_WIN) return;
|
|
const orig = process.env.HOME;
|
|
process.env.HOME = "/tmp/sf-test-home-userdata";
|
|
try {
|
|
expect(userDataDir()).toBe("/tmp/sf-test-home-userdata/.local/share/soulforge");
|
|
} finally {
|
|
if (orig !== undefined) process.env.HOME = orig;
|
|
else delete process.env.HOME;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("platform shim: archive dispatcher", () => {
|
|
test("extractArchive rejects unknown extensions", () => {
|
|
const r = extractArchive("/tmp/foo.weirdformat", "/tmp");
|
|
expect(r.success).toBe(false);
|
|
expect(r.error ?? "").toMatch(/unsupported/i);
|
|
});
|
|
|
|
test("extractTarGz / extractZip exist and are callable", () => {
|
|
// Smoke — just ensure the functions are wired. Real extraction runs on
|
|
// platforms that have tar.exe / tar / unzip available.
|
|
expect(typeof extractTarGz).toBe("function");
|
|
expect(typeof extractZip).toBe("function");
|
|
});
|
|
});
|
|
|
|
describe("platform shim: keychain (Windows only)", () => {
|
|
test.if(IS_WIN)("DPAPI roundtrip via crypt32.dll", async () => {
|
|
const { windowsKeychainSet, windowsKeychainGet, windowsKeychainDelete } = await import(
|
|
"../src/core/platform/keychain.js"
|
|
);
|
|
const key = `test-key-${Date.now()}`;
|
|
const value = `secret-${Math.random().toString(36).slice(2)}`;
|
|
const ok = windowsKeychainSet(key, value);
|
|
expect(ok).toBe(true);
|
|
const read = windowsKeychainGet(key);
|
|
expect(read).toBe(value);
|
|
const deleted = windowsKeychainDelete(key);
|
|
expect(deleted).toBe(true);
|
|
expect(windowsKeychainGet(key)).toBeNull();
|
|
});
|
|
|
|
test.if(!IS_WIN)("windowsKeychainGet returns null on non-Windows", async () => {
|
|
const { windowsKeychainGet, windowsKeychainAvailable } = await import(
|
|
"../src/core/platform/keychain.js"
|
|
);
|
|
expect(windowsKeychainAvailable()).toBe(false);
|
|
expect(windowsKeychainGet("anything")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("platform shim: cross-OS parity helpers", () => {
|
|
test("localAppData returns the raw LOCALAPPDATA on win32, null elsewhere", async () => {
|
|
const { localAppData } = await import("../src/core/platform/index.js");
|
|
const r = localAppData();
|
|
if (IS_WIN) {
|
|
// Redirected/customised profiles can point anywhere — assert against env, not a substring.
|
|
expect(r).toBe(process.env.LOCALAPPDATA ?? null);
|
|
} else {
|
|
expect(r).toBeNull();
|
|
}
|
|
});
|
|
|
|
test("expandHome expands ~/ on every platform", async () => {
|
|
const { expandHome } = await import("../src/core/platform/index.js");
|
|
const r = expandHome("~/foo");
|
|
expect(r.endsWith("foo") || r.endsWith("foo".replace("/", "\\"))).toBe(true);
|
|
expect(r.includes("~")).toBe(false);
|
|
});
|
|
|
|
test("expandHome leaves unrelated paths unchanged", async () => {
|
|
const { expandHome } = await import("../src/core/platform/index.js");
|
|
expect(expandHome("/etc/passwd")).toBe("/etc/passwd");
|
|
expect(expandHome("relative/path")).toBe("relative/path");
|
|
expect(expandHome("")).toBe("");
|
|
});
|
|
|
|
test("expandHome on win32 also expands ~\\\\", async () => {
|
|
if (!IS_WIN) return;
|
|
const { expandHome } = await import("../src/core/platform/index.js");
|
|
const r = expandHome("~\\foo");
|
|
expect(r.includes("~")).toBe(false);
|
|
expect(r.endsWith("foo")).toBe(true);
|
|
});
|
|
|
|
test("xdgConfigHome routes per platform", async () => {
|
|
const { xdgConfigHome } = await import("../src/core/platform/index.js");
|
|
const r = xdgConfigHome();
|
|
if (IS_WIN) {
|
|
// APPDATA may be redirected; honor the env value verbatim.
|
|
expect(r).toBe(process.env.APPDATA ?? r);
|
|
} else {
|
|
expect(r.length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
test("userFontDir is per-user, never empty", async () => {
|
|
const { userFontDir } = await import("../src/core/platform/index.js");
|
|
expect(userFontDir().length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("systemFontDirs starts with the per-user dir", async () => {
|
|
const { userFontDir, systemFontDirs } = await import("../src/core/platform/index.js");
|
|
const dirs = systemFontDirs();
|
|
expect(dirs.length).toBeGreaterThan(0);
|
|
expect(dirs[0]).toBe(userFontDir());
|
|
});
|
|
|
|
test("windowsPath is a no-op on POSIX", async () => {
|
|
if (IS_WIN) return;
|
|
const { windowsPath } = await import("../src/core/platform/index.js");
|
|
expect(windowsPath("/home/user/file")).toBe("/home/user/file");
|
|
expect(windowsPath("")).toBe("");
|
|
});
|
|
|
|
test("windowsPath translates Git Bash / Cygwin / WSL prefixes on win32", async () => {
|
|
if (!IS_WIN) return;
|
|
const { windowsPath } = await import("../src/core/platform/index.js");
|
|
expect(windowsPath("/c/Users/me")).toBe("C:/Users/me");
|
|
expect(windowsPath("/cygdrive/c/Users/me")).toBe("C:/Users/me");
|
|
expect(windowsPath("/mnt/c/Users/me")).toBe("C:/Users/me");
|
|
expect(windowsPath("C:/Users/me")).toBe("C:/Users/me");
|
|
});
|
|
|
|
test("matchGlob handles separator differences", async () => {
|
|
const { matchGlob } = await import("../src/core/platform/index.js");
|
|
expect(matchGlob("a/b/c.env", "**/*.env")).toBe(true);
|
|
expect(matchGlob("a\\b\\c.env", "**/*.env")).toBe(true);
|
|
expect(matchGlob("a/b/c.txt", "**/*.env")).toBe(false);
|
|
expect(matchGlob("", "**/*.env")).toBe(false);
|
|
});
|
|
|
|
test("matchGlob is case-insensitive on win32, exact on POSIX", async () => {
|
|
const { matchGlob } = await import("../src/core/platform/index.js");
|
|
if (IS_WIN) {
|
|
expect(matchGlob("C:/Users/Me/.ENV", "**/.env")).toBe(true);
|
|
} else {
|
|
expect(matchGlob("a/b/.ENV", "**/.env")).toBe(false);
|
|
expect(matchGlob("a/b/.env", "**/.env")).toBe(true);
|
|
}
|
|
});
|
|
|
|
test("canonicalPath is a no-op on POSIX", async () => {
|
|
if (IS_WIN) return;
|
|
const { canonicalPath } = await import("../src/core/platform/index.js");
|
|
expect(canonicalPath("/etc/hosts")).toBe("/etc/hosts");
|
|
});
|
|
});
|
|
|
|
describe("platform shim: console (Windows TTY only)", () => {
|
|
test("console helpers are callable + no-op on non-Windows / non-TTY", async () => {
|
|
const m = await import("../src/core/platform/console-win32.js");
|
|
expect(typeof m.disableProcessedInput).toBe("function");
|
|
expect(typeof m.flushInputBuffer).toBe("function");
|
|
expect(typeof m.installCtrlCGuard).toBe("function");
|
|
m.disableProcessedInput();
|
|
m.flushInputBuffer();
|
|
const unhook = m.installCtrlCGuard();
|
|
if (!IS_WIN) expect(unhook).toBeUndefined();
|
|
if (unhook) unhook();
|
|
});
|
|
});
|
|
|
|
describe("clipboard image read on Windows", () => {
|
|
// Source-level guard: keep this contract even though the implementation
|
|
// moved from per-paste `execFile` to a persistent `spawn` daemon. If a
|
|
// future refactor reverts to `-OutFile <path>` argv tricks, the test
|
|
// surfaces the regression on POSIX hosts without needing a Windows runner.
|
|
if (!IS_WIN) {
|
|
test("contract: no -OutFile/WriteAllBytes path in source (uses daemon)", async () => {
|
|
const src = await Bun.file(
|
|
new URL("../src/core/platform/clipboard.ts", import.meta.url),
|
|
).text();
|
|
// The old (broken) pattern: param([Parameter(Mandatory)][string]$OutFile)
|
|
// followed by argv `..., "-OutFile", tmpFile`. Either side is suspect
|
|
// on its own; both together is the bug.
|
|
expect(src).not.toMatch(/param\(\[Parameter\(Mandatory\)\]\[string\]\$OutFile\)/);
|
|
expect(src).not.toMatch(/"-OutFile",\s*tmpFile/);
|
|
// The persistent daemon writes base64 to stdout instead of a temp file.
|
|
expect(src).toMatch(/WindowsClipboardDaemon|startClipboardDaemon/);
|
|
});
|
|
return;
|
|
}
|
|
|
|
test("regression: in-flight reuse — concurrent calls share one read", async () => {
|
|
// Windows Terminal fires BOTH a keydown and a bracketed-paste event for
|
|
// the same Ctrl+V. The InputBox keydown handler and paste-event listener
|
|
// both call readClipboardImage(). Without a module-level in-flight cache,
|
|
// both callers would each send `READ` to the daemon — the daemon
|
|
// serialises one-at-a-time, so the second blocks until the first
|
|
// completes (~100-200ms). Sharing the in-flight promise collapses both
|
|
// into a single round-trip.
|
|
const { mock } = await import("bun:test");
|
|
const cp = await import("node:child_process");
|
|
const { EventEmitter } = await import("node:events");
|
|
const { PassThrough } = await import("node:stream");
|
|
|
|
const fakePng = Buffer.from([
|
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
|
]);
|
|
|
|
// Per-test state — every fakeSpawn invocation gets fresh streams, so
|
|
// each call to readClipboardImage() that needs a respawn starts from
|
|
// READY again. The first test call uses a delayed response to expose
|
|
// the in-flight cache.
|
|
let readCount = 0;
|
|
const makeFakeProc = () => {
|
|
const stdin = new PassThrough();
|
|
const stdout = new PassThrough();
|
|
const stderr = new PassThrough();
|
|
const emitter = new EventEmitter();
|
|
// Pretend PS is already past the Add-Type JIT and emit READY on the
|
|
// next tick. The daemon's onStdout handler resolves start() on the
|
|
// first line — so without this the test hangs.
|
|
queueMicrotask(() => stdout.write("READY\n"));
|
|
// On every READ\n on stdin, write back an OK response one tick later.
|
|
// (one-tick delay is the compressed analogue of the real ~100-200ms
|
|
// PowerShell round-trip; enough to keep p1's in-flight pending while
|
|
// p2 is queued.)
|
|
stdin.on("data", (chunk: Buffer) => {
|
|
const text = chunk.toString();
|
|
if (text.includes("READ")) {
|
|
readCount++;
|
|
queueMicrotask(() => {
|
|
stdout.write(`OK ${fakePng.toString("base64")}\nEND\n`);
|
|
});
|
|
}
|
|
});
|
|
const proc = Object.assign(emitter, {
|
|
stdin,
|
|
stdout,
|
|
stderr,
|
|
pid: 0,
|
|
kill: () => {
|
|
queueMicrotask(() => emitter.emit("exit", null, "SIGTERM"));
|
|
return true;
|
|
},
|
|
});
|
|
return proc;
|
|
};
|
|
|
|
// Track every spawn() call so we can assert how many daemons were started.
|
|
let spawnCount = 0;
|
|
const fakeSpawn = (..._args: unknown[]) => {
|
|
spawnCount++;
|
|
return makeFakeProc();
|
|
};
|
|
|
|
mock.module("node:child_process", () => ({
|
|
...cp,
|
|
spawn: fakeSpawn,
|
|
}));
|
|
|
|
try {
|
|
const { readClipboardImage } = await import(
|
|
`../src/core/platform/clipboard.ts?t=${Date.now()}`
|
|
);
|
|
// Fire two concurrent reads. The dispatcher must collapse the second
|
|
// into the first's in-flight promise — only one READ reaches the
|
|
// daemon's stdin.
|
|
const p1 = readClipboardImage();
|
|
// Yield so p1's async dispatcher reaches `await this.start()` and
|
|
// sends READ before p2 runs. Without this, both run synchronously
|
|
// and p2 sees `inFlightImage` still undefined.
|
|
await Promise.resolve();
|
|
const p2 = readClipboardImage();
|
|
const [r1, r2] = await Promise.all([p1, p2]);
|
|
expect(spawnCount).toBe(1);
|
|
expect(readCount).toBe(1);
|
|
expect(r1).not.toBeNull();
|
|
expect(r2).not.toBeNull();
|
|
// Both promises resolve to the same result object — same in-flight ref.
|
|
expect(r1).toBe(r2);
|
|
|
|
// After the in-flight settles, the cache must be cleared so the next
|
|
// genuine paste can re-run (a fresh READ to the same daemon).
|
|
const p3 = readClipboardImage();
|
|
const r3 = await p3;
|
|
expect(readCount).toBe(2);
|
|
expect(r3).not.toBeNull();
|
|
// p3 should be a new result object (not the in-flight cache).
|
|
expect(r3).not.toBe(r1);
|
|
} finally {
|
|
mock.restore();
|
|
}
|
|
});
|
|
|
|
test("regression: daemon waits for READY before serving reads", async () => {
|
|
// Without the READY sentinel, the first read would race with PS's
|
|
// Add-Type JIT. If the daemon resolved start() on spawn() return, the
|
|
// caller would write `READ` to a PS that hasn't loaded the clipboard
|
|
// assembly yet — GetImage() throws, the daemon returns NO, and the
|
|
// image appears to be missing.
|
|
const { mock } = await import("bun:test");
|
|
const cp = await import("node:child_process");
|
|
const { EventEmitter } = await import("node:events");
|
|
const { PassThrough } = await import("node:stream");
|
|
|
|
const fakePng = Buffer.from([
|
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
|
]);
|
|
|
|
const stdin = new PassThrough();
|
|
const stdout = new PassThrough();
|
|
const stderr = new PassThrough();
|
|
const emitter = new EventEmitter();
|
|
|
|
// Deliberately delay READY by 50ms — long enough that the read() call
|
|
// would block on start() if start() correctly waits for the sentinel.
|
|
// If start() were to resolve eagerly, the test would record READ
|
|
// arriving before READY, surfacing the race.
|
|
setTimeout(() => stdout.write("READY\n"), 50);
|
|
|
|
let readSeen = false;
|
|
stdin.on("data", (chunk: Buffer) => {
|
|
if (chunk.toString().includes("READ")) {
|
|
readSeen = true;
|
|
queueMicrotask(() => {
|
|
stdout.write(`OK ${fakePng.toString("base64")}\nEND\n`);
|
|
});
|
|
}
|
|
});
|
|
|
|
const fakeSpawn = () =>
|
|
Object.assign(emitter, {
|
|
stdin,
|
|
stdout,
|
|
stderr,
|
|
pid: 0,
|
|
kill: () => true,
|
|
});
|
|
|
|
mock.module("node:child_process", () => ({ ...cp, spawn: fakeSpawn }));
|
|
|
|
try {
|
|
const { readClipboardImage } = await import(
|
|
`../src/core/platform/clipboard.ts?t=${Date.now()}`
|
|
);
|
|
const t0 = Date.now();
|
|
const result = await readClipboardImage();
|
|
const elapsed = Date.now() - t0;
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.mediaType).toBe("image/png");
|
|
// READ must arrive only AFTER READY was emitted, so it must take
|
|
// >= ~45ms (the READY delay minus timing jitter).
|
|
expect(readSeen).toBe(true);
|
|
expect(elapsed).toBeGreaterThanOrEqual(40);
|
|
} finally {
|
|
mock.restore();
|
|
}
|
|
});
|
|
|
|
test("regression: daemon respawns after process exit", async () => {
|
|
// If the PowerShell process dies mid-session (e.g. user kills it,
|
|
// machine goes to sleep, the PS host crashes), the next read should
|
|
// transparently respawn — not hang on a dead pipe.
|
|
//
|
|
// Test tactic: fake spawn() emits `exit` on its emitter 50ms after
|
|
// the fake proc starts. The first read's start() resolves on READY,
|
|
// the proc stays alive long enough to respond, then exits. The
|
|
// second read respawns to recover from the dead pipe.
|
|
const { mock } = await import("bun:test");
|
|
const cp = await import("node:child_process");
|
|
const { EventEmitter } = await import("node:events");
|
|
const { PassThrough } = await import("node:stream");
|
|
|
|
const fakePng = Buffer.from([
|
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
|
]);
|
|
|
|
let spawnCount = 0;
|
|
const fakeSpawn = () => {
|
|
spawnCount++;
|
|
const stdin = new PassThrough();
|
|
const stdout = new PassThrough();
|
|
const emitter = new EventEmitter();
|
|
queueMicrotask(() => stdout.write("READY\n"));
|
|
stdin.on("data", (chunk: Buffer) => {
|
|
if (chunk.toString().includes("READ")) {
|
|
queueMicrotask(() => {
|
|
stdout.write(`OK ${fakePng.toString("base64")}\nEND\n`);
|
|
});
|
|
}
|
|
});
|
|
// Let the first read complete, THEN kill the proc. The next read
|
|
// must respawn to recover from the dead pipe.
|
|
setTimeout(() => {
|
|
emitter.emit("exit", null, null);
|
|
}, 50);
|
|
return Object.assign(emitter, {
|
|
stdin,
|
|
stdout,
|
|
stderr: new PassThrough(),
|
|
pid: 0,
|
|
kill: () => true,
|
|
});
|
|
};
|
|
|
|
mock.module("node:child_process", () => ({ ...cp, spawn: fakeSpawn }));
|
|
|
|
try {
|
|
const { readClipboardImage } = await import(
|
|
`../src/core/platform/clipboard.ts?t=${Date.now()}`
|
|
);
|
|
// First read — spawns, READY fires, READ gets a response, r1
|
|
// resolves. The fake proc is still alive at this point.
|
|
const r1 = await readClipboardImage();
|
|
expect(r1).not.toBeNull();
|
|
expect(spawnCount).toBe(1);
|
|
|
|
// Wait for the fake proc's setTimeout(50ms) to fire and emit exit.
|
|
// Until then the daemon is still "alive" and the next read would
|
|
// happily reuse it.
|
|
await new Promise((r) => setTimeout(r, 80));
|
|
|
|
// Second read — the daemon is dead, so a new spawn must happen.
|
|
const r2 = await readClipboardImage();
|
|
expect(r2).not.toBeNull();
|
|
expect(spawnCount).toBe(2);
|
|
} finally {
|
|
mock.restore();
|
|
}
|
|
});
|
|
});
|