mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 13:42:50 +00:00
Shape rows in native Canvas and CoreText, then place clusters on xterm columns so fallback font advances cannot separate text from its cursor. Wait for measured dimensions before attachment and repaint overhanging ink with affected rows. Use Android platform ICU to avoid bundling approximately 14 MB of data; retain and document the Android 10 conjunct limitation.
198 lines
7.3 KiB
JavaScript
198 lines
7.3 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { execFileSync } from "node:child_process";
|
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { homedir, tmpdir } from "node:os";
|
|
import { resolve } from "node:path";
|
|
import { setTimeout } from "node:timers/promises";
|
|
import { PNG } from "pngjs";
|
|
import {
|
|
checkPartialSelection,
|
|
checkTerminalScreenshot,
|
|
selectionTarget,
|
|
writeOverlay,
|
|
} from "./check.mjs";
|
|
import { fixtureOutput } from "./fixture.mjs";
|
|
|
|
// Renders the fixture in a real Paseo terminal and checks the screenshot with `check.mjs`, then
|
|
// selects one cell of a conjunct and checks a second screenshot.
|
|
//
|
|
// Preconditions: the app runs the build being verified against a daemon on the same host as
|
|
// this script, and shows an idle, focused terminal with an empty shell prompt.
|
|
//
|
|
// PLATFORM=android ANDROID_SERIAL=emulator-5554 node e2e/mobile/terminal-rendering/render.mjs
|
|
// PLATFORM=ios IOS_DEVICE="iPhone 17" node e2e/mobile/terminal-rendering/render.mjs
|
|
const platform = process.env.PLATFORM ?? "android";
|
|
const appId = process.env.PASEO_TERMINAL_RENDERING_APP_ID ?? "sh.paseo.debug";
|
|
const label = process.env.PASEO_TERMINAL_RENDERING_LABEL ?? "run";
|
|
const session = process.env.AGENT_DEVICE_SESSION ?? `terminal-rendering-${platform}`;
|
|
const artifacts = resolve(
|
|
process.env.PASEO_TERMINAL_RENDERING_ARTIFACTS ??
|
|
resolve(homedir(), ".paseo/artifacts/terminal-rendering", platform),
|
|
label,
|
|
);
|
|
const fixtureDir = process.env.PASEO_TERMINAL_RENDERING_FIXTURE_DIR ?? tmpdir();
|
|
// iOS simulators screenshot at one pixel per point unless asked; glyph identity needs the
|
|
// device's real density. Android screenshots are always in device pixels.
|
|
const screenshotArgs =
|
|
platform === "ios"
|
|
? ["--pixel-density", process.env.PASEO_TERMINAL_RENDERING_PIXEL_DENSITY ?? "3"]
|
|
: [];
|
|
const env = {
|
|
...process.env,
|
|
AGENT_DEVICE_STATE_DIR:
|
|
process.env.AGENT_DEVICE_STATE_DIR ??
|
|
resolve(homedir(), ".paseo/artifacts/terminal-rendering/device-state"),
|
|
};
|
|
mkdirSync(artifacts, { recursive: true });
|
|
|
|
function ad(...args) {
|
|
return execFileSync("agent-device", [...args, "--session", session], {
|
|
env,
|
|
encoding: "utf8",
|
|
timeout: 60_000,
|
|
});
|
|
}
|
|
|
|
function deviceArgs() {
|
|
if (platform === "android") {
|
|
return [
|
|
"--platform",
|
|
"android",
|
|
...(process.env.ANDROID_SERIAL ? ["--serial", process.env.ANDROID_SERIAL] : []),
|
|
];
|
|
}
|
|
return [
|
|
"--platform",
|
|
"ios",
|
|
...(process.env.IOS_DEVICE ? ["--device", process.env.IOS_DEVICE] : []),
|
|
];
|
|
}
|
|
|
|
function snapshotNodes() {
|
|
return JSON.parse(ad("snapshot", "--json")).data.nodes;
|
|
}
|
|
|
|
function rowNodes(nodes) {
|
|
const rows = new Map();
|
|
for (const node of nodes) {
|
|
const match = /^terminal-row-(\d+)$/.exec(node.identifier ?? "");
|
|
if (match && node.rect) rows.set(Number(match[1]), node);
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
/**
|
|
* Taps a control at its center. On iOS the keyboard toolbar is a full-screen accessibility node
|
|
* listed after the key bar, so pressing by selector reports the keys as covered.
|
|
*/
|
|
function pressControl(id) {
|
|
const node = JSON.parse(ad("snapshot", "-i", "--json")).data.nodes.find(
|
|
(candidate) => candidate.identifier === id,
|
|
);
|
|
assert.ok(node?.rect, `No ${id} control on screen`);
|
|
const { x, y, width, height } = node.rect;
|
|
ad("press", String(Math.round(x + width / 2)), String(Math.round(y + height / 2)));
|
|
}
|
|
|
|
async function runCommand(command) {
|
|
ad("type", command, "--delay-ms", "0");
|
|
pressControl("terminal-key-enter");
|
|
await setTimeout(2_000);
|
|
}
|
|
|
|
async function terminalSize() {
|
|
await runCommand("clear; stty size");
|
|
for (const node of snapshotNodes()) {
|
|
const match = /^(\d+) (\d+)$/.exec((node.label ?? "").trim());
|
|
if (match && (node.identifier ?? "").startsWith("terminal-row-")) {
|
|
return { rows: Number(match[1]), columns: Number(match[2]) };
|
|
}
|
|
}
|
|
throw new Error("No terminal row showed the `stty size` output");
|
|
}
|
|
|
|
ad("open", appId, ...deviceArgs());
|
|
const controls = JSON.parse(ad("snapshot", "-i", "--json")).data.nodes;
|
|
const hasControl = (id) => controls.some((node) => node.identifier === id);
|
|
assert.ok(hasControl("terminal-keyboard-toggle"), "Open a terminal first");
|
|
if (!hasControl("terminal-key-enter")) pressControl("terminal-keyboard-toggle");
|
|
|
|
const size = await terminalSize();
|
|
const fixturePath = resolve(fixtureDir, "paseo-terminal-rendering.txt");
|
|
const scriptPath = resolve(fixtureDir, "paseo-terminal-rendering.sh");
|
|
writeFileSync(fixturePath, fixtureOutput(size.columns));
|
|
writeFileSync(scriptPath, `printf '\\033[2J\\033[3J\\033[H'; cat '${fixturePath}'; sleep 120\n`);
|
|
await runCommand(`sh ${scriptPath}`);
|
|
|
|
const nodes = snapshotNodes();
|
|
const screenshotPath = resolve(artifacts, "terminal.png");
|
|
ad("screenshot", screenshotPath, ...screenshotArgs);
|
|
const png = PNG.sync.read(readFileSync(screenshotPath));
|
|
// Snapshot rects are in points on iOS and pixels on Android; the checks work in screenshot pixels.
|
|
const scale = png.width / Math.max(...nodes.map((node) => node.rect?.width ?? 0));
|
|
const toPixels = ({ x, y, width, height }) => ({
|
|
x: x * scale,
|
|
y: y * scale,
|
|
width: width * scale,
|
|
height: height * scale,
|
|
});
|
|
const rows = rowNodes(nodes);
|
|
const rects = new Map([...rows].map(([index, node]) => [index, toPixels(node.rect)]));
|
|
const { grid, failures, slots } = await checkTerminalScreenshot({ png, size, rows: rects });
|
|
|
|
// Long-press selects the conjunct as a word; dragging back from its second cell leaves the first.
|
|
const target = await selectionTarget({ size });
|
|
const targetRect = rects.get(target.row);
|
|
assert.ok(targetRect, "The select line is not on screen");
|
|
const y = Math.round((targetRect.y + targetRect.height / 2) / scale);
|
|
const cellX = (offset) => Math.round((grid.left + (target.col + offset) * grid.cellWidth) / scale);
|
|
ad("longpress", String(cellX(0.5)), String(y), "800");
|
|
ad("gesture", "pan", String(cellX(1.75)), String(y), String(cellX(0.5) - cellX(1.75)), "0", "600");
|
|
const selectionPath = resolve(artifacts, "selection.png");
|
|
ad("screenshot", selectionPath, ...screenshotArgs);
|
|
const selectionFailures = checkPartialSelection({
|
|
png: PNG.sync.read(readFileSync(selectionPath)),
|
|
rows: rects,
|
|
grid,
|
|
target,
|
|
});
|
|
for (const failure of selectionFailures) failures.push({ line: "select", ...failure });
|
|
// A tap clears the selection; it would otherwise survive into the next run's screenshot.
|
|
const lastRect = rects.get(size.rows - 1);
|
|
if (lastRect) {
|
|
ad("press", String(cellX(4)), String(Math.round((lastRect.y + lastRect.height / 2) / scale)));
|
|
}
|
|
try {
|
|
pressControl("terminal-key-ctrl");
|
|
ad("type", "c", "--delay-ms", "0");
|
|
} catch (error) {
|
|
console.warn(`Could not interrupt the fixture: ${error.message}`);
|
|
}
|
|
|
|
writeOverlay(png, slots, resolve(artifacts, "overlay.png"));
|
|
const report = {
|
|
platform,
|
|
label,
|
|
size,
|
|
grid,
|
|
checkedClusters: slots.length,
|
|
failures,
|
|
rows: [...rows.keys()].map((index) => ({
|
|
index,
|
|
label: rows.get(index).label,
|
|
rect: rects.get(index),
|
|
})),
|
|
};
|
|
writeFileSync(resolve(artifacts, "report.json"), `${JSON.stringify(report, null, 2)}\n`);
|
|
|
|
const byLine = Map.groupBy(failures, (failure) => failure.line);
|
|
console.log(
|
|
`${label}: ${slots.length} clusters checked, ${failures.length} failures (${artifacts})`,
|
|
);
|
|
for (const [line, list] of byLine) {
|
|
console.log(
|
|
` ${line}: ${list.map((failure) => `${failure.kind}@${failure.col ?? "-"}${failure.char ? `(${failure.char})` : ""}`).join(" ")}`,
|
|
);
|
|
}
|
|
process.exitCode = failures.length === 0 ? 0 : 1;
|