evOo-local/infra/sandboxes/supervisor/src/mobile-keyboard.test.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

298 lines
11 KiB
TypeScript

import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
attachMobileTrackpad,
isTouchBrowser,
mobileInputChanges,
} from "../../computer/mobile-keyboard.js";
type TouchLike = { identifier: number; clientX: number; clientY: number };
type TouchEventLike = {
changedTouches: TouchLike[];
touches: TouchLike[];
preventDefault: () => void;
stopPropagation: () => void;
defaultPrevented: boolean;
};
/** A fake noVNC surface: a 200x100 canvas at (0,100) inside a 200x300 free area. */
function trackpadFixture() {
const listeners = new Map<string, (event: TouchEventLike) => void>();
const buttonListeners = new Map<string, () => void>();
const mouseEvents: { type: string; clientX: number; clientY: number }[] = [];
const overlay = {
style: { display: "none" },
events: [] as string[],
dispatchEvent(event: { type: string }) {
this.events.push(event.type);
this.style.display = "none";
return true;
},
};
const canvas = {
getBoundingClientRect: () => ({
left: 0,
top: 100,
right: 200,
bottom: 200,
width: 200,
height: 100,
}),
dispatchEvent: (event: { type: string; clientX: number; clientY: number }) => {
mouseEvents.push({ type: event.type, clientX: event.clientX, clientY: event.clientY });
if (event.type === "mousedown") overlay.style.display = "";
return true;
},
};
const surface = {
addEventListener: (type: string, listener: (event: TouchEventLike) => void) =>
listeners.set(type, listener),
removeEventListener: () => {},
querySelector: () => canvas,
classList: { toggle: () => {}, remove: () => {} },
};
const button = {
hidden: true,
addEventListener: (type: string, listener: () => void) => buttonListeners.set(type, listener),
removeEventListener: () => {},
setAttribute: () => {},
classList: { toggle: () => {} },
};
const rfb = { viewOnly: false, showDotCursor: false };
// noVNC's mouse capture overlay is shown after a mousedown and must receive the mouseup.
const documentTarget = {
getElementById: (id: string) => (id === "noVNC_mouse_capture_elem" ? overlay : null),
};
const detach = attachMobileTrackpad(rfb, { button, surface, documentTarget, sensitivity: 1 });
buttonListeners.get("click")?.();
const touch = (
type: string,
identifier: number,
clientX: number,
clientY: number,
others: TouchLike[] = [],
batched: TouchLike[] = [],
) => {
const changed = [{ identifier, clientX, clientY }, ...batched];
const event: TouchEventLike = {
changedTouches: changed,
touches: type === "touchend" || type === "touchcancel" ? others : [...changed, ...others],
defaultPrevented: false,
preventDefault() {
this.defaultPrevented = true;
},
stopPropagation: () => {},
};
listeners.get(type)?.(event);
return event;
};
return { touch, mouseEvents, overlay, detach };
}
describe("mobile computer keyboard", () => {
it("translates inserted and deleted text", () => {
expect(mobileInputChanges("___", "___a", 4)).toEqual({
backspaces: 0,
text: "a",
});
expect(mobileInputChanges("___a", "___", 3)).toEqual({
backspaces: 1,
text: "",
});
});
it("replaces corrected text instead of duplicating it", () => {
expect(mobileInputChanges("___teh", "___the", 6)).toEqual({
backspaces: 2,
text: "he",
});
});
it("only enables the control in touch browsers", () => {
expect(isTouchBrowser({ maxTouchPoints: 1 }, {})).toBe(true);
expect(isTouchBrowser({ maxTouchPoints: 0 }, { ontouchstart: null })).toBe(true);
expect(isTouchBrowser({ maxTouchPoints: 0 }, {})).toBe(false);
});
it("enables a visible relative-pointer mode", () => {
const listeners = new Map<string, () => void>();
const classes = new Set<string>();
const button = {
hidden: true,
addEventListener: (type: string, listener: () => void) => listeners.set(type, listener),
removeEventListener: () => {},
setAttribute: () => {},
classList: {
toggle: (name: string, on: boolean) => (on ? classes.add(name) : classes.delete(name)),
},
};
const surface = {
addEventListener: () => {},
removeEventListener: () => {},
querySelector: () => null,
classList: { toggle: () => {}, remove: () => {} },
};
const rfb = { viewOnly: false, showDotCursor: false };
const detach = attachMobileTrackpad(rfb, { button, surface, documentTarget: {} });
listeners.get("click")?.();
expect(button.hidden).toBe(false);
expect(rfb.showDotCursor).toBe(true);
expect(classes.has("active")).toBe(true);
detach();
expect(rfb.showDotCursor).toBe(false);
});
it("ships and initializes the keyboard bridge in the computer image", () => {
const root = path.resolve(import.meta.dirname, "../../computer");
const dockerfile = readFileSync(path.join(root, "Dockerfile"), "utf8");
const embed = readFileSync(path.join(root, "embed.html"), "utf8");
const start = readFileSync(path.join(root, "start.sh"), "utf8");
const supervisor = readFileSync(path.join(import.meta.dirname, "index.ts"), "utf8");
expect(dockerfile).toMatch(/mobile-keyboard\.js/);
expect(embed).toMatch(/attachMobileKeyboard/);
expect(embed).toMatch(/mobile-keyboard-input/);
expect(embed).toMatch(/attachMobileTrackpad/);
expect(embed).toMatch(/mobile-trackpad/);
expect(embed).toMatch(/mobile-keyboard-open #screen/);
expect(embed).toMatch(/--mobile-visual-height/);
expect(start).toMatch(/mobile-keyboard\.js/);
expect(supervisor).toMatch(/"mobile-keyboard\.js"/);
});
});
describe("mobile trackpad touches", () => {
const stubMouseEvent = () => {
const previous = (globalThis as { MouseEvent?: unknown }).MouseEvent;
(globalThis as { MouseEvent?: unknown }).MouseEvent = class {
type: string;
clientX: number;
clientY: number;
constructor(type: string, init: { clientX: number; clientY: number }) {
this.type = type;
this.clientX = init.clientX;
this.clientY = init.clientY;
}
};
return () => {
(globalThis as { MouseEvent?: unknown }).MouseEvent = previous;
};
};
it("moves the pointer relative to a drag in the free area and clicks where it is on a tap", () => {
const restore = stubMouseEvent();
try {
const fixture = trackpadFixture();
expect(fixture.mouseEvents.at(-1)).toEqual({ type: "mousemove", clientX: 100, clientY: 150 });
const start = fixture.touch("touchstart", 1, 50, 250);
expect(start.defaultPrevented).toBe(true);
fixture.touch("touchmove", 1, 70, 260);
fixture.touch("touchend", 1, 70, 260);
expect(fixture.mouseEvents.at(-1)).toEqual({ type: "mousemove", clientX: 120, clientY: 160 });
fixture.touch("touchstart", 2, 30, 280);
const end = fixture.touch("touchend", 2, 30, 280);
expect(end.defaultPrevented).toBe(true);
expect(fixture.mouseEvents.at(-1)).toEqual({ type: "mousedown", clientX: 120, clientY: 160 });
expect(fixture.overlay.events).toEqual(["mouseup"]);
expect(fixture.overlay.style.display).toBe("none");
fixture.detach();
} finally {
restore();
}
});
it("leaves touches on the desktop to noVNC and follows the pointer there", () => {
const restore = stubMouseEvent();
try {
const fixture = trackpadFixture();
const start = fixture.touch("touchstart", 1, 40, 120);
expect(start.defaultPrevented).toBe(false);
const end = fixture.touch("touchend", 1, 40, 120);
expect(end.defaultPrevented).toBe(false);
expect(fixture.mouseEvents.filter((event) => event.type === "mousedown")).toHaveLength(0);
fixture.touch("touchstart", 2, 100, 250);
fixture.touch("touchmove", 2, 110, 250);
expect(fixture.mouseEvents.at(-1)).toEqual({ type: "mousemove", clientX: 50, clientY: 120 });
fixture.detach();
} finally {
restore();
}
});
it("follows a drag on the desktop so the next free-area move continues from there", () => {
const restore = stubMouseEvent();
try {
const fixture = trackpadFixture();
fixture.touch("touchstart", 1, 40, 120);
fixture.touch("touchmove", 1, 90, 170);
fixture.touch("touchend", 1, 90, 170);
fixture.touch("touchstart", 2, 100, 250);
fixture.touch("touchmove", 2, 110, 250);
expect(fixture.mouseEvents.at(-1)).toEqual({ type: "mousemove", clientX: 100, clientY: 170 });
fixture.detach();
} finally {
restore();
}
});
it("ignores extra fingers during a free-area gesture and never clicks for them", () => {
const restore = stubMouseEvent();
try {
const fixture = trackpadFixture();
fixture.touch("touchstart", 1, 50, 250);
fixture.touch("touchmove", 1, 60, 250);
const second = fixture.touch("touchstart", 2, 150, 280, [
{ identifier: 1, clientX: 60, clientY: 250 },
]);
expect(second.defaultPrevented).toBe(true);
fixture.touch("touchmove", 2, 120, 280, [{ identifier: 1, clientX: 60, clientY: 250 }]);
expect(fixture.mouseEvents.at(-1)).toEqual({ type: "mousemove", clientX: 110, clientY: 150 });
fixture.touch("touchend", 2, 120, 280, [{ identifier: 1, clientX: 60, clientY: 250 }]);
expect(fixture.mouseEvents.filter((event) => event.type === "mousedown")).toHaveLength(0);
fixture.touch("touchmove", 1, 70, 250);
expect(fixture.mouseEvents.at(-1)).toEqual({ type: "mousemove", clientX: 120, clientY: 150 });
fixture.detach();
} finally {
restore();
}
});
it("classifies every touch of a batched touchstart", () => {
const restore = stubMouseEvent();
try {
const fixture = trackpadFixture();
// One finger lands on the desktop and another in the free area in the same event.
const start = fixture.touch(
"touchstart",
1,
40,
120,
[],
[{ identifier: 2, clientX: 100, clientY: 250 }],
);
expect(start.defaultPrevented).toBe(true);
fixture.touch("touchmove", 2, 110, 250, [{ identifier: 1, clientX: 40, clientY: 120 }]);
expect(fixture.mouseEvents.at(-1)).toEqual({ type: "mousemove", clientX: 50, clientY: 120 });
fixture.detach();
} finally {
restore();
}
});
it("starts a fresh gesture when a previous touch never reported its end", () => {
const restore = stubMouseEvent();
try {
const fixture = trackpadFixture();
fixture.touch("touchstart", 1, 50, 250);
fixture.touch("touchmove", 1, 60, 250);
// No touchend for touch 1. The next touch must still drive the trackpad.
fixture.touch("touchstart", 2, 150, 280);
fixture.touch("touchmove", 2, 140, 280);
expect(fixture.mouseEvents.at(-1)).toEqual({ type: "mousemove", clientX: 100, clientY: 150 });
fixture.detach();
} finally {
restore();
}
});
});