Files
paseo/packages/app/e2e/mobile/composer-keyboard/ios.mjs
Mohamed Boudraandsam 99a1d9a7a2 fix(app): keep the iPhone skill picker above the composer and keyboard (#7679)
Keep iOS command and file suggestions above the composer and keyboard across chat, workspace drafts and New workspace. Share transform-free anchor measurement and keyboard translation through keyboard/shift.

Includes failing-first native placement and exact-insertion coverage, plus two CI test reliability fixes.

Co-authored-by: sam <76036008+masz77@users.noreply.github.com>
2026-10-23 11:41:00 +07:00

230 lines
9.3 KiB
JavaScript

// Composer autocomplete placement on iOS with the software keyboard: chat, workspace
// draft tab, and New workspace. Run where agent-device reaches the simulator, with the
// development app connected to the fixture host. See docs/mobile-testing.md.
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import { resolveAutocompletePlacement } from "./autocomplete-placement.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../../..");
const env = (name, fallback) => process.env[`PASEO_COMPOSER_KEYBOARD_${name}`] ?? fallback;
const serverId = env("SERVER_ID");
const agentId = env("AGENT_ID");
const workspaceId = env("WORKSPACE_ID");
if (!serverId || !agentId || !workspaceId) {
throw new Error(
"Set PASEO_COMPOSER_KEYBOARD_SERVER_ID, _AGENT_ID (an idle chat) and _WORKSPACE_ID (its workspace)",
);
}
const draftId = env("DRAFT_ID", "composer-keyboard");
const session = env("SESSION", "composer-keyboard-ios");
const device = env("DEVICE", "iPhone 16 Pro");
const artifactsDir = path.join(repoRoot, ".dev/agent-device-artifacts/composer-keyboard-ios");
const grownPrefix = "grown line one\ngrown line two\ngrown line three\n";
// Partial queries with known best matches. `inserted` is the composer text after
// tapping the best match.
const fileCase = { text: "@app", expected: "packages/app", inserted: '"packages/app"' };
// The release skills ship in this repository's .claude/skills.
const skillCase = { text: "/release", expected: "/release-beta", inserted: "/release-beta " };
// The chat fixture's mock provider lists one command.
const chatCommandCase = { text: "/mo", expected: "/mock", inserted: "/mock " };
fs.mkdirSync(artifactsDir, { recursive: true });
function ad(...args) {
return execFileSync("agent-device", [...args, "--session", session], {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
}
function snapshot(name) {
const output = ad("snapshot", "--raw", "--json");
fs.writeFileSync(path.join(artifactsDir, `${name}.json`), output);
ad("screenshot", path.join(artifactsDir, `${name}.png`));
return JSON.parse(output);
}
const nodeById = (snap, id) => snap.data.nodes.find((node) => node.identifier === id && node.rect);
const keyboardShown = (snap) => snap.data.nodes.some((node) => node.type === "Keyboard");
const composerText = (snap) =>
snap.data.nodes.find((node) => node.type === "TextView" && node.rect)?.value ?? "";
async function waitFor(name, predicate, timeoutMs = 10_000) {
const deadline = Date.now() + timeoutMs;
for (;;) {
const snap = snapshot(name);
if (predicate(snap)) return snap;
if (Date.now() > deadline) throw new Error(`Timed out waiting for ${name}`);
await sleep(500);
}
}
// Keyboard motion has settled once two snapshots agree on the composer position.
async function settledPlacement(name, keyboard) {
let previousTop;
return waitFor(name, (snap) => {
const composer = nodeById(snap, "message-input-root");
if (!nodeById(snap, "composer-autocomplete-popover") || !composer) return false;
if (keyboardShown(snap) !== keyboard) return false;
const stable = composer.rect.y === previousTop;
previousTop = composer.rect.y;
return stable;
});
}
function assertPlacement(name, snap, expected) {
const placement = resolveAutocompletePlacement(snap, { expected });
process.stdout.write(
`PASS ${name}: popover ${Math.round(placement.popover.y)}-${Math.round(
placement.popover.y + placement.popover.height,
)} above composer ${Math.round(placement.composer.y)}, ${expected} drawn\n`,
);
return placement;
}
// iOS has no generic keyboard dismissal. Tapping the chat stream dismisses it; a
// draft has no stream, so open and close the model sheet, which takes input focus.
async function dismissKeyboard(snap) {
const composer = nodeById(snap, "message-input-root").rect;
ad("press", "4", String(Math.round(composer.y - 8)));
await sleep(1_000);
if (!keyboardShown(snapshot("dismiss-keyboard"))) return;
const sheetOpen = (sheet) =>
sheet.data.nodes.some((node) => node.type === "Button" && node.label === "Close");
ad("press", 'id="combined-model-selector"');
await waitFor("dismiss-keyboard-sheet", sheetOpen);
ad("press", 'label="Close"');
// The input is not hittable until the sheet has gone.
await waitFor("dismiss-keyboard-done", (done) => !sheetOpen(done) && !keyboardShown(done));
}
// Tap past the end of the (short) last line so the caret lands after the mention.
// The input's right padding holds the fullscreen toggle; stay clear of it.
function focusAtEnd(snap) {
const input = snap.data.nodes.find((node) => node.type === "TextView" && node.rect)?.rect;
if (!input) throw new Error("Missing composer input");
ad(
"press",
String(Math.round(input.x + input.width * 0.7)),
String(Math.round(input.y + input.height - 6)),
);
}
// `fill` cannot replace a multi-line draft or write an empty one, so delete from the end.
async function clearInput(name) {
focusAtEnd(snapshot(`${name}-before-clear`));
const keyboard = await waitFor(`${name}-clear`, keyboardShown);
const deleteKey = keyboard.data.nodes.find(
(node) => node.type === "Key" && node.label === "delete" && node.rect,
)?.rect;
if (!deleteKey) throw new Error("Missing keyboard delete key");
const length = composerText(keyboard).length;
ad(
"press",
String(Math.round(deleteKey.x + deleteKey.width / 2)),
String(Math.round(deleteKey.y + deleteKey.height / 2)),
"--count",
String(length + 2),
"--interval-ms",
"40",
);
}
// The accessibility value drops a trailing space.
const sameText = (actual, expected) => actual.trimEnd() === expected.trimEnd();
async function runCase(host, kind, { text, expected, inserted }) {
const name = `${host}-${kind}`;
await clearInput(name);
await dismissKeyboard(snapshot(`${name}-empty`));
// fill taps the input and types at once, so suggestions open while the keyboard opens.
ad("fill", "editable=true", text);
assertPlacement(
`${name} opened while keyboard opens`,
await settledPlacement(`${name}-opening`, true),
expected,
);
await clearInput(`${name}-settled`);
await waitFor(`${name}-settled-empty`, (snap) => composerText(snap) === "");
ad("type", text);
assertPlacement(
`${name} opened after keyboard settled`,
await settledPlacement(`${name}-open`, true),
expected,
);
await dismissKeyboard(snapshot(`${name}-before-close`));
assertPlacement(
`${name} keyboard closed`,
await settledPlacement(`${name}-closed`, false),
expected,
);
focusAtEnd(snapshot(`${name}-before-reopen`));
const reopened = assertPlacement(
`${name} keyboard opened under open suggestions`,
await settledPlacement(`${name}-reopened`, true),
expected,
);
ad("press", String(reopened.x), String(reopened.y));
const selected = await waitFor(`${name}-selected`, (snap) =>
sameText(composerText(snap), inserted),
);
if (!keyboardShown(selected)) throw new Error(`Selecting ${expected} closed the keyboard`);
process.stdout.write(
`PASS ${name}: tapped ${expected}, composer now ${JSON.stringify(inserted)}\n`,
);
}
// Drafts list commands for their selected provider, and a fresh install selects none.
async function selectClaudeModel(host) {
ad("press", 'id="combined-model-selector"');
await waitFor(`${host}-model-sheet`, (snap) => Boolean(nodeById(snap, "agent-controls-model")));
ad("press", 'id="agent-controls-model"');
await waitFor(`${host}-providers`, (snap) => Boolean(nodeById(snap, "model-provider-claude")));
ad("press", 'id="model-provider-claude"');
const models = await waitFor(`${host}-models`, (snap) =>
snap.data.nodes.some((node) => node.identifier?.startsWith("model-row-claude-") && node.rect),
);
const row = models.data.nodes.find(
(node) => node.identifier?.startsWith("model-row-claude-") && node.rect,
).rect;
ad(
"press",
String(Math.round(row.x + row.width / 2)),
String(Math.round(row.y + row.height / 2)),
);
await waitFor(`${host}-model-selected`, (snap) =>
Boolean(nodeById(snap, "agent-controls-thinking")),
);
ad("press", 'label="Close"');
await waitFor(`${host}-model-closed`, (snap) => !nodeById(snap, "agent-controls-model"));
}
async function runHost(host) {
if (host !== "chat") await selectClaudeModel(host);
await runCase(host, "commands", host === "chat" ? chatCommandCase : skillCase);
await runCase(host, "files", fileCase);
await runCase(host, "files-grown", {
text: `${grownPrefix}${fileCase.text}`,
expected: fileCase.expected,
inserted: `${grownPrefix}${fileCase.inserted}`,
});
await clearInput(`${host}-done`);
await dismissKeyboard(snapshot(`${host}-done`));
}
const hosts = [
["chat", `paseo://h/${serverId}/agent/${agentId}`],
["workspace-draft", `paseo://h/${serverId}/workspace/${workspaceId}?open=draft%3A${draftId}`],
["new-workspace", `paseo://new?serverId=${serverId}`],
];
for (const [host, url] of hosts) {
ad("open", url, "--platform", "ios", "--device", device);
await waitFor(host, (snap) => Boolean(nodeById(snap, "message-input-root")), 60_000);
await runHost(host);
}
process.stdout.write(`Composer autocomplete placement passed. Artifacts: ${artifactsDir}\n`);