## Objective Fix missing characters in GPUI's web renderer for emoji-capable symbols that default to text presentation, such as repeated bare spider webs (`🕸🕸🕸`). The default Canvas policy previously rejected these symbols before checking whether bundled fonts could render them. ## Solution Separate emoji eligibility from presentation. Emoji-capable graphemes can now use Canvas when their glyph is missing, while existing bundled text glyphs remain native and explicit text/emoji presentation remains intact. CJK fallback remains opt-in. Add regression coverage for repeated spider webs and mixed bare, VS15, and VS16 sequences, including every glyph's source index and presentation, missing-glyph reports, copy preservation, and raster-cache reuse. ## Testing - `cargo test -p gpui_web --lib`: all 19 tests passed. - `cargo fmt -p gpui_web -- --check`, example Rust formatting, and `git diff --check`: passed. - `trunk build` in `crates/gpui_web/examples/hello_web`: passed. - Headless Chrome on macOS: browser regression failed against the old implementation and passed with the fix. Inspected the resulting screenshot; all mixed-presentation spider webs render. - Firefox, Safari, and other operating systems have not been exercised. To run the browser workflow, serve the gallery with `trunk serve` from `crates/gpui_web/examples/hello_web`, then run `node crates/gpui_web/examples/hello_web/test_canvas_fallback.mjs http://127.0.0.1:8080` from the repository root. Release Notes: - [GPUI] Fixed missing text-default emoji, including spider-web symbols, in GPUI's web renderer.
204 lines
8.7 KiB
JavaScript
204 lines
8.7 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { spawn } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const origin = new URL(process.argv[2] ?? "http://127.0.0.1:8080").origin;
|
|
const chrome = process.env.CHROME ??
|
|
(process.platform === "darwin"
|
|
? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
|
: "chromium");
|
|
const output = path.join(
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
"target",
|
|
"canvas-fallback-test",
|
|
String(Date.now()),
|
|
);
|
|
fs.mkdirSync(output, { recursive: true });
|
|
const log = fs.openSync(path.join(output, "chrome.log"), "w");
|
|
const browser = spawn(chrome, [
|
|
"--headless=new",
|
|
"--no-first-run",
|
|
"--no-default-browser-check",
|
|
"--remote-debugging-port=0",
|
|
`--user-data-dir=${path.join(output, "profile")}`,
|
|
"about:blank",
|
|
], { stdio: ["ignore", log, log] });
|
|
let launchError;
|
|
browser.on("error", error => { launchError = error; });
|
|
const delay = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
let socket;
|
|
|
|
try {
|
|
let port;
|
|
for (let attempt = 0; attempt < 100; attempt++) {
|
|
if (launchError) throw launchError;
|
|
if (browser.exitCode !== null) throw new Error(`Chrome exited; see ${output}/chrome.log`);
|
|
const portFile = path.join(output, "profile", "DevToolsActivePort");
|
|
if (fs.existsSync(portFile)) {
|
|
port = fs.readFileSync(portFile, "utf8").split("\n")[0];
|
|
break;
|
|
}
|
|
await delay(100);
|
|
}
|
|
assert.ok(port, "Chrome did not start");
|
|
const pages = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
|
|
socket = new WebSocket(pages.find(page => page.type === "page").webSocketDebuggerUrl);
|
|
await new Promise((resolve, reject) => {
|
|
socket.addEventListener("open", resolve, { once: true });
|
|
socket.addEventListener("error", reject, { once: true });
|
|
});
|
|
|
|
let nextId = 0;
|
|
const pending = new Map();
|
|
const errors = [];
|
|
socket.addEventListener("message", ({ data }) => {
|
|
const message = JSON.parse(data);
|
|
if (message.id) {
|
|
const request = pending.get(message.id);
|
|
if (!request) return;
|
|
pending.delete(message.id);
|
|
clearTimeout(request.timer);
|
|
if (message.error) request.reject(new Error(JSON.stringify(message.error)));
|
|
else request.resolve(message.result);
|
|
} else if (message.method === "Runtime.exceptionThrown" ||
|
|
(message.method === "Runtime.consoleAPICalled" && message.params.type === "error") ||
|
|
(message.method === "Log.entryAdded" && message.params.entry.text.includes("willReadFrequently"))) {
|
|
errors.push(message.params);
|
|
}
|
|
});
|
|
const call = (method, params = {}) => new Promise((resolve, reject) => {
|
|
const id = ++nextId;
|
|
const timer = setTimeout(() => {
|
|
pending.delete(id);
|
|
reject(new Error(`Timed out: ${method}`));
|
|
}, 15000);
|
|
timer.unref();
|
|
pending.set(id, { resolve, reject, timer });
|
|
socket.send(JSON.stringify({ id, method, params }));
|
|
});
|
|
const evaluate = async expression => {
|
|
const result = await call("Runtime.evaluate", {
|
|
expression, returnByValue: true, awaitPromise: true,
|
|
});
|
|
assert.equal(result.exceptionDetails, undefined, JSON.stringify(result.exceptionDetails));
|
|
return result.result.value;
|
|
};
|
|
await call("Runtime.enable");
|
|
await call("Page.enable");
|
|
await call("Log.enable");
|
|
await call("Emulation.setDeviceMetricsOverride", {
|
|
width: 1280, height: 900, deviceScaleFactor: 1, mobile: false,
|
|
});
|
|
await call("Page.addScriptToEvaluateOnNewDocument", { source: `
|
|
window.canvasCalls = [];
|
|
for (const name of ["measureText", "fillText"]) {
|
|
const original = OffscreenCanvasRenderingContext2D.prototype[name];
|
|
OffscreenCanvasRenderingContext2D.prototype[name] = function(...args) {
|
|
canvasCalls.push({
|
|
type: name, text: args[0], font: this.font,
|
|
willReadFrequently: this.getContextAttributes().willReadFrequently,
|
|
});
|
|
return original.apply(this, args);
|
|
};
|
|
}
|
|
` });
|
|
await call("Page.navigate", { url: `${origin}/input` });
|
|
for (let attempt = 0; attempt < 150; attempt++) {
|
|
if (await evaluate('!!document.querySelector("textarea")')) break;
|
|
await delay(100);
|
|
}
|
|
assert.ok(await evaluate('!!document.querySelector("textarea")'), "Input example did not launch");
|
|
await evaluate(`(async () => {
|
|
const tests = await import("/font_fallback_test.js");
|
|
await tests.default();
|
|
tests.test_missing_glyph_notifications();
|
|
})()`);
|
|
await evaluate("canvasCalls.length = 0");
|
|
await evaluate("new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))");
|
|
// Read GPUI's buffer through its Copy action without changing the system clipboard.
|
|
await evaluate(`
|
|
window.copiedText = "";
|
|
Object.defineProperty(navigator.clipboard, "writeText", {
|
|
value: async text => { copiedText = text; },
|
|
});
|
|
window.sendKey = (key, metaKey = false) => {
|
|
for (const type of ["keydown", "keyup"]) {
|
|
document.querySelector("textarea").dispatchEvent(
|
|
new KeyboardEvent(type, { key, metaKey, bubbles: true, cancelable: true }),
|
|
);
|
|
}
|
|
};
|
|
`);
|
|
const key = (key, meta = false) => evaluate(`sendKey(${JSON.stringify(key)}, ${meta})`);
|
|
const insert = text => call("Input.insertText", { text });
|
|
const readText = async () => {
|
|
await key("a", true);
|
|
await evaluate('copiedText = ""');
|
|
await key("c", true);
|
|
const text = await evaluate("copiedText");
|
|
await key("ArrowRight");
|
|
return text;
|
|
};
|
|
const rasterCount = text => evaluate(
|
|
`canvasCalls.filter(call => call.type === "fillText" && call.text === ${JSON.stringify(text)}).length`,
|
|
);
|
|
|
|
await insert("Hello ©");
|
|
await delay(200);
|
|
assert.equal(await evaluate("canvasCalls.length"), 0, "Bundled glyphs should not use Canvas");
|
|
await key("a", true);
|
|
await insert("😀😀😀");
|
|
await delay(200);
|
|
assert.equal(await rasterCount("😀"), 1, "Repeated emoji should share one atlas image");
|
|
assert.equal(await readText(), "😀😀😀");
|
|
await key("ArrowLeft");
|
|
await key("ArrowRight");
|
|
await delay(200);
|
|
assert.equal(await rasterCount("😀"), 1, "Caret movement should not rerasterize emoji");
|
|
|
|
await key("a", true);
|
|
await insert("🕸🕸🕸");
|
|
await delay(200);
|
|
assert.equal(await rasterCount("🕸"), 1, "Repeated text-default emoji should share one atlas image");
|
|
assert.equal(await readText(), "🕸🕸🕸");
|
|
await key("ArrowLeft");
|
|
await key("ArrowRight");
|
|
await delay(200);
|
|
assert.equal(await rasterCount("🕸"), 1, "Caret movement should not rerasterize text-default emoji");
|
|
|
|
const text = "A中文 が か\u3099 각 각 🕸 🕸\uFE0F 🕸\uFE0E 🕸 😀 ❤️ 👍🏽 🇯🇵 1️⃣ 👩💻 👨👩👧👦Z";
|
|
await key("a", true);
|
|
await insert(text);
|
|
await delay(200);
|
|
assert.equal(await readText(), text);
|
|
for (const grapheme of ["🕸", "🕸\uFE0F", "🕸\uFE0E", "❤️", "👍🏽", "🇯🇵", "1️⃣", "👩💻", "👨👩👧👦"]) {
|
|
assert.ok(await rasterCount(grapheme) > 0, `Missing whole-grapheme raster: ${grapheme}`);
|
|
}
|
|
for (const grapheme of ["中", "文", "が", "か\u3099", "각", "각"]) {
|
|
assert.equal(await rasterCount(grapheme), 0, `Default policy should not rasterize CJK: ${grapheme}`);
|
|
}
|
|
assert.ok(
|
|
await evaluate("canvasCalls.every(call => call.willReadFrequently === true)"),
|
|
"Glyph canvases should be configured for frequent readback",
|
|
);
|
|
const screenshot = await call("Page.captureScreenshot", { format: "png" });
|
|
fs.writeFileSync(path.join(output, "input.png"), Buffer.from(screenshot.data, "base64"));
|
|
await key("Backspace");
|
|
await key("Backspace");
|
|
assert.equal(await readText(), text.slice(0, -"👨👩👧👦Z".length));
|
|
|
|
await key("a", true);
|
|
await evaluate("canvasCalls = []");
|
|
await insert("ش");
|
|
await delay(200);
|
|
assert.equal(await evaluate("canvasCalls.length"), 0, "Unsupported scripts should stay native");
|
|
assert.deepEqual(errors, [], "Browser reported errors");
|
|
console.log(`Canvas fallback checks passed. Screenshot and browser log: ${output}`);
|
|
} finally {
|
|
socket?.close();
|
|
browser.kill();
|
|
fs.closeSync(log);
|
|
}
|