|
| 1 | +import assert from "node:assert/strict"; |
| 2 | +import childProcess from "node:child_process"; |
| 3 | +import { syncBuiltinESMExports } from "node:module"; |
| 4 | +import { test, type TestContext } from "node:test"; |
| 5 | +import { readClipboardHtmlAsync } from "../ui/core/clipboard"; |
| 6 | + |
| 7 | +function mockReader(t: TestContext, platform: NodeJS.Platform, outputs: (string | Error)[]) { |
| 8 | + const descriptor = Object.getOwnPropertyDescriptor(process, "platform")!; |
| 9 | + Object.defineProperty(process, "platform", { value: platform }); |
| 10 | + const calls: { command: string; args: string[]; options: childProcess.ExecFileOptions }[] = []; |
| 11 | + t.mock.method( |
| 12 | + childProcess, |
| 13 | + "execFile", |
| 14 | + ( |
| 15 | + command: string, |
| 16 | + args: string[], |
| 17 | + options: childProcess.ExecFileOptions, |
| 18 | + callback: (error: Error | null, stdout: string) => void |
| 19 | + ) => { |
| 20 | + const output = outputs[calls.length] ?? new Error("missing helper"); |
| 21 | + calls.push({ command, args, options }); |
| 22 | + queueMicrotask(() => callback(output instanceof Error ? output : null, typeof output === "string" ? output : "")); |
| 23 | + } |
| 24 | + ); |
| 25 | + syncBuiltinESMExports(); |
| 26 | + t.after(() => { |
| 27 | + Object.defineProperty(process, "platform", descriptor); |
| 28 | + t.mock.restoreAll(); |
| 29 | + syncBuiltinESMExports(); |
| 30 | + }); |
| 31 | + return calls; |
| 32 | +} |
| 33 | + |
| 34 | +const HTML = '<p>中文 😀 <a href="https://example.com">链接</a></p>'; |
| 35 | + |
| 36 | +test("macOS explicitly reads public.html via AppKit", async (t) => { |
| 37 | + const calls = mockReader(t, "darwin", [HTML]); |
| 38 | + assert.equal(await readClipboardHtmlAsync(), HTML); |
| 39 | + assert.equal(calls[0].command, "osascript"); |
| 40 | + assert.deepEqual(calls[0].args.slice(0, 3), ["-l", "JavaScript", "-e"]); |
| 41 | + assert.match(calls[0].args[3], /NSPasteboard\.generalPasteboard\.stringForType\("public.html"\)/); |
| 42 | +}); |
| 43 | + |
| 44 | +test("Windows extracts the selected fragment using UTF-8 byte offsets", async (t) => { |
| 45 | + const template = |
| 46 | + "Version:1.0\r\nStartHTML:0000000000\r\nEndHTML:0000000000\r\nStartFragment:0000000000\r\nEndFragment:0000000000\r\n"; |
| 47 | + const prefix = "<html><body>未选中的中文<!--StartFragment-->"; |
| 48 | + const suffix = "<!--EndFragment-->未选中的尾部</body></html>"; |
| 49 | + const startHTML = Buffer.byteLength(template); |
| 50 | + const startFragment = startHTML + Buffer.byteLength(prefix); |
| 51 | + const endFragment = startFragment + Buffer.byteLength(HTML); |
| 52 | + const header = template |
| 53 | + .replace(/StartHTML:\d+/, `StartHTML:${String(startHTML).padStart(10, "0")}`) |
| 54 | + .replace(/EndHTML:\d+/, `EndHTML:${String(endFragment + Buffer.byteLength(suffix)).padStart(10, "0")}`) |
| 55 | + .replace(/StartFragment:\d+/, `StartFragment:${String(startFragment).padStart(10, "0")}`) |
| 56 | + .replace(/EndFragment:\d+/, `EndFragment:${String(endFragment).padStart(10, "0")}`); |
| 57 | + const calls = mockReader(t, "win32", [header + prefix + HTML + suffix + "\0"]); |
| 58 | + assert.equal(await readClipboardHtmlAsync(), HTML); |
| 59 | + assert.equal(calls[0].command, "powershell"); |
| 60 | + assert.ok(calls[0].args.includes("-STA")); |
| 61 | + assert.match(calls[0].args.at(-1)!, /UTF8Encoding/); |
| 62 | + assert.match(calls[0].args.at(-1)!, /TextDataFormat\]::Html/); |
| 63 | +}); |
| 64 | + |
| 65 | +for (const [name, value, expected] of [ |
| 66 | + [ |
| 67 | + "offsets inside the header", |
| 68 | + `Version:1.0\r\nStartFragment:0\r\nEndFragment:11\r\n<!--StartFragment-->${HTML}<!--EndFragment-->`, |
| 69 | + HTML, |
| 70 | + ], |
| 71 | + ["missing offsets", `Version:1.0\r\n<html>before<!--StartFragment-->${HTML}<!--EndFragment-->after</html>`, HTML], |
| 72 | + [ |
| 73 | + "invalid offsets", |
| 74 | + `Version:1.0\r\nStartFragment:9000\r\nEndFragment:9999\r\n<!--StartFragment-->${HTML}<!--EndFragment-->`, |
| 75 | + HTML, |
| 76 | + ], |
| 77 | + [ |
| 78 | + "reversed offsets", |
| 79 | + `Version:1.0\r\nStartFragment:100\r\nEndFragment:20\r\n<!--StartFragment-->${HTML}<!--EndFragment-->`, |
| 80 | + HTML, |
| 81 | + ], |
| 82 | + [ |
| 83 | + "HTML fallback", |
| 84 | + `Version:1.0\r\nStartHTML:-1\r\nEndHTML:-1\r\n<html><body>${HTML}</body></html>\0`, |
| 85 | + `<html><body>${HTML}</body></html>`, |
| 86 | + ], |
| 87 | + ["raw HTML", HTML, HTML], |
| 88 | + ["header only", "Version:1.0\r\nStartHTML:-1\r\nEndHTML:-1\r\n", null], |
| 89 | + ["empty fragment", "Version:1.0\r\n<!--StartFragment--><!--EndFragment-->", null], |
| 90 | +] as const) { |
| 91 | + test(`Windows handles ${name}`, async (t) => { |
| 92 | + mockReader(t, "win32", [value]); |
| 93 | + assert.equal(await readClipboardHtmlAsync(), expected); |
| 94 | + }); |
| 95 | +} |
| 96 | + |
| 97 | +test("Windows falls back to the HTML byte range when fragment offsets and markers are absent", async (t) => { |
| 98 | + const header = "Version:1.0\r\nStartHTML:0000000000\r\nEndHTML:0000000000\r\n"; |
| 99 | + const value = header |
| 100 | + .replace(/StartHTML:\d+/, `StartHTML:${String(Buffer.byteLength(header)).padStart(10, "0")}`) |
| 101 | + .replace(/EndHTML:\d+/, `EndHTML:${String(Buffer.byteLength(header + HTML)).padStart(10, "0")}`); |
| 102 | + mockReader(t, "win32", [value + HTML + "\0"]); |
| 103 | + assert.equal(await readClipboardHtmlAsync(), HTML); |
| 104 | +}); |
| 105 | + |
| 106 | +test("Linux reads text/html from xclip", async (t) => { |
| 107 | + const calls = mockReader(t, "linux", [HTML]); |
| 108 | + assert.equal(await readClipboardHtmlAsync(), HTML); |
| 109 | + assert.deepEqual( |
| 110 | + calls.map(({ command, args }) => [command, args]), |
| 111 | + [["xclip", ["-selection", "clipboard", "-t", "text/html", "-o"]]] |
| 112 | + ); |
| 113 | +}); |
| 114 | + |
| 115 | +for (const output of ["", " \n", new Error("ENOENT"), new Error("timeout"), new Error("maxBuffer exceeded")]) { |
| 116 | + test(`HTML read failure (${String(output)}) returns null`, async (t) => { |
| 117 | + const calls = mockReader(t, "darwin", [output]); |
| 118 | + assert.equal(await readClipboardHtmlAsync(), null); |
| 119 | + assert.equal(calls[0].options.timeout, 3000); |
| 120 | + assert.equal(calls[0].options.maxBuffer, 32 * 1024 * 1024); |
| 121 | + assert.equal(calls[0].options.encoding, "utf8"); |
| 122 | + }); |
| 123 | + test(`Linux falls back to wl-paste after ${String(output)}`, async (t) => { |
| 124 | + const calls = mockReader(t, "linux", [output, HTML]); |
| 125 | + assert.equal(await readClipboardHtmlAsync(), HTML); |
| 126 | + assert.deepEqual( |
| 127 | + calls.map(({ command }) => command), |
| 128 | + ["xclip", "wl-paste"] |
| 129 | + ); |
| 130 | + assert.deepEqual(calls[1].args, ["--type", "text/html", "--no-newline"]); |
| 131 | + }); |
| 132 | +} |
| 133 | + |
| 134 | +test("Linux returns null when both clipboard helpers fail", async (t) => { |
| 135 | + mockReader(t, "linux", []); |
| 136 | + assert.equal(await readClipboardHtmlAsync(), null); |
| 137 | +}); |
| 138 | + |
| 139 | +test("unsupported platforms return null without spawning", async (t) => { |
| 140 | + const calls = mockReader(t, "freebsd", []); |
| 141 | + assert.equal(await readClipboardHtmlAsync(), null); |
| 142 | + assert.equal(calls.length, 0); |
| 143 | +}); |
0 commit comments