const { test, expect } = require("./fixtures.cjs");
const { execSync } = require("child_process");
const BASE = process.env.MOBUX_URL || "https://localhost:5151";
const USER = process.env.MOBUX_USER || "";
const PASS = process.env.MOBUX_PASS || "";
const AUTH =
USER && PASS
? "Basic " + Buffer.from(`${USER}:${PASS}`).toString("base64")
: null;
const SESSION = process.env.MOBUX_TEST_SESSION || "mobux-conformance";
const { createTmuxRunner } = require("./lib/tmux.cjs");
const SANDBOX_HOME = process.env.MOBUX_TEST_HOME || "/tmp/mobux-smoke/home";
const SHELL_ENV = `-e HISTFILE=/dev/null -e HOME=${SANDBOX_HOME}`;
const tmux = createTmuxRunner("mobux-test");
test.use({
...(AUTH ? { extraHTTPHeaders: { Authorization: AUTH } } : {}),
});
test.beforeAll(() => {
try {
tmux(`kill-session -t ${SESSION}`);
} catch (_) {}
tmux(`new-session -d -s ${SESSION} ${SHELL_ENV} "bash --norc --noprofile"`);
tmux(`send-keys -t ${SESSION} "PS1='\\$ '" Enter`);
tmux(`send-keys -t ${SESSION} "clear" Enter`);
execSync("sleep 0.3");
});
test.afterAll(() => {
try {
tmux(`kill-session -t ${SESSION}`);
} catch (_) {}
});
async function boot(page) {
await page.goto(`${BASE}/app#/s/${SESSION}`);
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 8000,
});
await page.waitForFunction(
() => window.__mobuxView?.test?.wsReady?.() === true,
{ timeout: 15000 },
);
}
async function bufferHasText(page, marker) {
return page.evaluate((m) => {
const len = window.__mobuxView.test.bufferLength();
for (let y = 0; y < len; y++) {
const t = window.__mobuxView.test.lineText(y);
if (t && t.includes(m)) return true;
}
return false;
}, marker);
}
test("R1: create mounts the engine, navigation disposes it", async ({
page,
}) => {
await boot(page);
expect(await page.evaluate(() => !!window.__mobuxView)).toBe(true);
await page.goto(`${BASE}/app#/`);
await page.waitForFunction(() => window.__mobuxView === undefined, {
timeout: 8000,
});
expect(await page.evaluate(() => window.__mobuxView === undefined)).toBe(
true,
);
});
test("R2: write resolves after the buffer reflects the data", async ({
page,
}) => {
await boot(page);
await page.evaluate(() =>
window.__mobuxView.test.inject("CONFORMANCE_R2_MARKER\n"),
);
expect(await bufferHasText(page, "CONFORMANCE_R2_MARKER")).toBe(true);
});
test("R3/R4: measure and current grid are positive", async ({ page }) => {
await boot(page);
const dims = await page.evaluate(() => ({
m: window.__mobuxView.test.measure(),
cell: window.__mobuxView.test.cellMetrics(),
cols: window.__mobuxView.test.cols(),
rows: window.__mobuxView.test.rows(),
}));
expect(dims.m.cols).toBeGreaterThan(0);
expect(dims.m.rows).toBeGreaterThan(0);
expect(dims.m.cellWidth).toBeGreaterThan(0);
expect(dims.m.cellHeight).toBeGreaterThan(0);
expect(dims.cell.width).toBeGreaterThan(0);
expect(dims.cell.height).toBeGreaterThan(0);
expect(dims.cols).toBeGreaterThan(0);
expect(dims.rows).toBeGreaterThan(0);
});
test("R5: keystrokes typed into the renderer reach the PTY", async ({
page,
}) => {
await boot(page);
await page.evaluate(() => {
window.__mobuxView.test.setNativeInputEnabled(true);
window.__mobuxView.test.focus();
});
await page.keyboard.type("echo R5_CONFORMANCE_OK\r");
await page.waitForFunction(
() => {
const len = window.__mobuxView.test.bufferLength();
for (let y = 0; y < len; y++) {
const t = window.__mobuxView.test.lineText(y);
if (t && t.includes("R5_CONFORMANCE_OK")) return true;
}
return false;
},
{ timeout: 15000 },
);
});
test("R6: scrollLines and scrollToBottom move the viewport", async ({
page,
}) => {
await boot(page);
await page.evaluate(() => window.__mobuxView.test.injectLines(300, "r6seed"));
await page.waitForFunction(
() => window.__mobuxView.test.bufferLength() > 200,
{ timeout: 10000 },
);
await page.evaluate(() => window.__mobuxView.test.scrollToBottom());
const bottom = await page.evaluate(() => window.__mobuxView.test.viewportY());
await page.evaluate(() => window.__mobuxView.test.scrollLines(-10));
const scrolled = await page.evaluate(() =>
window.__mobuxView.test.viewportY(),
);
expect(scrolled).toBeLessThan(bottom);
await page.evaluate(() => window.__mobuxView.test.scrollToBottom());
const back = await page.evaluate(() => window.__mobuxView.test.viewportY());
expect(back).toBe(bottom);
});
test("R7: viewport and row text read the display", async ({ page }) => {
await boot(page);
await page.evaluate(() =>
window.__mobuxView.test.inject("R7_ROW_TEXT_XYZ\n"),
);
const probe = await page.evaluate(() => ({
length: window.__mobuxView.test.bufferLength(),
top: window.__mobuxView.test.viewportY(),
rows: window.__mobuxView.test.rows(),
}));
expect(probe.length).toBeGreaterThanOrEqual(probe.rows);
expect(probe.top).toBeGreaterThanOrEqual(0);
expect(await bufferHasText(page, "R7_ROW_TEXT_XYZ")).toBe(true);
});
test("document: a written line reaches the document with its attrs", async ({
page,
}) => {
await boot(page);
await page.evaluate(() =>
window.__mobuxView.test.inject("plain \x1b[1mDOC_BOLD_XYZ\x1b[0m\n"),
);
const line = await page.evaluate(() =>
window.__mobuxView.test
.documentSnapshot()
.lines.find((l) => l.text.includes("DOC_BOLD_XYZ")),
);
expect(line).toBeDefined();
expect(line.text).toBe("plain DOC_BOLD_XYZ");
const bold = line.runs.find((r) => r.text === "DOC_BOLD_XYZ");
expect(bold.attrs.bold).toBe(true);
expect(line.runs[0].attrs.bold).toBe(false);
});
test("buffer: onBufferChanged fires after a write", async ({ page }) => {
await boot(page);
const observed = await page.evaluate(
() =>
new Promise((resolve) => {
let done = false;
const settle = (v) => {
if (!done) {
done = true;
resolve(v);
}
};
window.__mobuxView.test.awaitBufferChange().then(() => settle(true));
setTimeout(() => settle(false), 5000);
window.__mobuxView.test.writeData("R8_CHANGE\r\n");
}),
);
expect(observed).toBe(true);
});
test("R9: reports the normal screen after an alternate-screen exit", async ({
page,
}) => {
await boot(page);
await page.evaluate(() =>
window.__mobuxView.test.inject("R9_NORMAL_SCREEN\n"),
);
expect(await page.evaluate(() => window.__mobuxView.test.isAlternate())).toBe(
false,
);
});
test("R16: the alternate screen switches in the buffer and leaves no scrollback", async ({
page,
}) => {
await boot(page);
await page.evaluate(() => window.__mobuxView.test.inject(""));
await page.evaluate(() =>
window.__mobuxView.test.writeData("\x1b[?1049;25h"),
);
expect(await page.evaluate(() => window.__mobuxView.test.isAlternate())).toBe(
true,
);
await page.evaluate(() =>
window.__mobuxView.test.writeData(
"\x1b[2J\x1b[HR16SAVE\x1b[?1002;1048h\r\nR16OTHR\x1b[?1048lZ",
),
);
expect(await bufferHasText(page, "R16SAVEZ")).toBe(true);
let alt = "";
for (let i = 0; i < 200; i++) alt += `R16_ALT_ONLY ${i}\r\n`;
await page.evaluate((s) => window.__mobuxView.test.writeData(s), alt);
await page.evaluate(() => window.__mobuxView.test.writeData("\x1b[?1049l"));
expect(await page.evaluate(() => window.__mobuxView.test.isAlternate())).toBe(
false,
);
expect(await bufferHasText(page, "R16_ALT_ONLY")).toBe(false);
expect(await bufferHasText(page, "R16SAVE")).toBe(false);
});
test("R10: OSC 133 markers are detected via registerOscHandler", async ({
page,
}) => {
await boot(page);
expect(await page.evaluate(() => window.__mobuxView.test.oscDetected())).toBe(
false,
);
await page.evaluate(() =>
window.__mobuxView.test.writeData("\x1b]133;A\x07prompt$ \r\n"),
);
await page.waitForFunction(
() => window.__mobuxView.test.oscDetected() === true,
{ timeout: 5000 },
);
expect(
await page.evaluate(() => window.__mobuxView.test.oscMarkerCount()),
).toBeGreaterThan(0);
});
test("R11: setFontSize / getFontSize round-trip", async ({ page }) => {
await boot(page);
const result = await page.evaluate(() => {
const before = window.__mobuxView.test.getFontSize();
window.__mobuxView.test.setFontSize(before + 3);
const after = window.__mobuxView.test.getFontSize();
window.__mobuxView.test.setFontSize(before);
return { before, after, restored: window.__mobuxView.test.getFontSize() };
});
expect(result.after).toBe(result.before + 3);
expect(result.restored).toBe(result.before);
});
test("R15: focus and setNativeInputEnabled are callable", async ({ page }) => {
await boot(page);
const ok = await page.evaluate(() => {
window.__mobuxView.test.setNativeInputEnabled(false);
window.__mobuxView.test.setNativeInputEnabled(true);
window.__mobuxView.test.setNativeInputEnabled(false);
window.__mobuxView.test.focus();
return true;
});
expect(ok).toBe(true);
});
test("R12: selectAll, getSelection, onSelectionChange, clearSelection", async ({
page,
}) => {
await boot(page);
await page.evaluate(() =>
window.__mobuxView.test.inject("R12_SELECTION_MARKER\n"),
);
await page.waitForFunction(() => window.__mobuxView.test.bufferLength() > 0, {
timeout: 8000,
});
const changePromise = page.evaluate(
() =>
new Promise((resolve) => {
let done = false;
const settle = (v) => {
if (!done) {
done = true;
resolve(v);
}
};
window.__mobuxView.test.awaitSelectionChange().then(() => settle(true));
setTimeout(() => settle(false), 5000);
}),
);
await page.waitForTimeout(50);
await page.evaluate(() => window.__mobuxView.test.selectAll());
expect(await changePromise).toBe(true);
expect(
await page.evaluate(() => window.__mobuxView.test.hasSelection()),
).toBe(true);
const selected = await page.evaluate(() =>
window.__mobuxView.test.getSelection(),
);
expect(selected).toContain("R12_SELECTION_MARKER");
await page.evaluate(() => window.__mobuxView.test.clearSelection());
expect(
await page.evaluate(() => window.__mobuxView.test.hasSelection()),
).toBe(false);
});
test("R13: URL activation is reported through onLink", async ({ page }) => {
await boot(page);
const URL = "https://example.com/r13-conformance";
await page.evaluate(() => {
const o = document.getElementById("touchOverlay");
if (o) o.style.pointerEvents = "none";
const q = document.getElementById("loadquote");
if (q) q.remove();
});
await page.evaluate(
(u) => window.__mobuxView.test.inject(`open ${u}\x1b[K\n`),
URL,
);
await page.evaluate(() => window.__mobuxView.test.scrollToBottom());
await page.waitForFunction(
(u) => {
const len = window.__mobuxView.test.bufferLength();
for (let y = 0; y < len; y++) {
const t = window.__mobuxView.test.lineText(y);
if (t && t.includes(u)) return true;
}
return false;
},
URL,
{ timeout: 10000 },
);
const point = await page.evaluate((u) => {
const len = window.__mobuxView.test.bufferLength();
const vy = window.__mobuxView.test.viewportY();
const rows = window.__mobuxView.test.rows();
const cell = window.__mobuxView.test.cellMetrics();
const rect = document.getElementById("terminal").getBoundingClientRect();
for (let y = vy; y < Math.min(len, vy + rows); y++) {
const t = window.__mobuxView.test.lineText(y);
const idx = t ? t.indexOf(u) : -1;
if (idx >= 0) {
const col = idx + Math.floor(u.length / 2);
return {
x: rect.left + (col + 0.5) * cell.width,
y: rect.top + (y - vy + 0.5) * cell.height,
};
}
}
return null;
}, URL);
expect(point).not.toBeNull();
const linkPromise = page.evaluate(
() =>
new Promise((resolve) => {
let done = false;
const settle = (v) => {
if (!done) {
done = true;
resolve(v);
}
};
window.__mobuxView.test.awaitLink().then((uri) => settle(uri));
setTimeout(() => settle(null), 6000);
}),
);
await page.waitForTimeout(100);
await page.mouse.move(point.x, point.y);
await page.mouse.move(point.x, point.y);
await page.mouse.click(point.x, point.y);
expect(await linkPromise).toBe(URL);
});
test("R13: trailing punctuation is not part of the link", async ({ page }) => {
await boot(page);
const URL = "https://example.com/r13-boundary";
await page.evaluate(() => {
const o = document.getElementById("touchOverlay");
if (o) o.style.pointerEvents = "none";
const q = document.getElementById("loadquote");
if (q) q.remove();
});
await page.evaluate(
(u) => window.__mobuxView.test.inject(`see ${u}.\x1b[K\n`),
URL,
);
await page.evaluate(() => window.__mobuxView.test.scrollToBottom());
await page.waitForFunction(
(u) => {
const len = window.__mobuxView.test.bufferLength();
for (let y = 0; y < len; y++) {
const t = window.__mobuxView.test.lineText(y);
if (t && t.includes(`${u}.`)) return true;
}
return false;
},
URL,
{ timeout: 10000 },
);
const point = await page.evaluate((u) => {
const len = window.__mobuxView.test.bufferLength();
const vy = window.__mobuxView.test.viewportY();
const rows = window.__mobuxView.test.rows();
const cell = window.__mobuxView.test.cellMetrics();
const rect = document.getElementById("terminal").getBoundingClientRect();
for (let y = vy; y < Math.min(len, vy + rows); y++) {
const t = window.__mobuxView.test.lineText(y);
const idx = t ? t.indexOf(u) : -1;
if (idx >= 0) {
const col = idx + Math.floor(u.length / 2);
return {
x: rect.left + (col + 0.5) * cell.width,
y: rect.top + (y - vy + 0.5) * cell.height,
};
}
}
return null;
}, URL);
expect(point).not.toBeNull();
const linkPromise = page.evaluate(
() =>
new Promise((resolve) => {
let done = false;
const settle = (v) => {
if (!done) {
done = true;
resolve(v);
}
};
window.__mobuxView.test.awaitLink().then((uri) => settle(uri));
setTimeout(() => settle(null), 6000);
}),
);
await page.waitForTimeout(100);
await page.mouse.move(point.x, point.y);
await page.mouse.move(point.x, point.y);
await page.mouse.click(point.x, point.y);
expect(await linkPromise).toBe(URL);
});
test("R14: terminal BEL is reported through onBell", async ({ page }) => {
await boot(page);
const observed = await page.evaluate(
() =>
new Promise((resolve) => {
let done = false;
const settle = (v) => {
if (!done) {
done = true;
resolve(v);
}
};
window.__mobuxView.test.awaitBell().then(() => settle(true));
setTimeout(() => settle(false), 5000);
window.__mobuxView.test.writeData("\x07");
}),
);
expect(observed).toBe(true);
});
test("bell: a raw client-side BEL never triggers the chime", async ({
page,
}) => {
await boot(page);
await page.waitForFunction(() => typeof window.__mobuxChime === "function", {
timeout: 8000,
});
const chimed = await page.evaluate(
() =>
new Promise((resolve) => {
let called = false;
const original = window.__mobuxChime;
window.__mobuxChime = (...args) => {
called = true;
return original(...args);
};
window.__mobuxView.test.awaitBell().then(() => {
setTimeout(() => resolve(called), 200);
});
window.__mobuxView.test.writeData("\x07");
}),
);
expect(chimed).toBe(false);
});
test("device queries: a DA query gets exactly one reply", async ({ page }) => {
await page.addInitScript(() => {
window.__sent = [];
const send = WebSocket.prototype.send;
WebSocket.prototype.send = function (data) {
window.__sent.push({ data, at: Date.now() });
return send.call(this, data);
};
});
const isDA = (d) => typeof d === "string" && /^\x1b\[[?>][\d;]*c$/.test(d);
const daReplies = () =>
page.evaluate(
(src) =>
window.__sent.filter(({ data }) =>
new Function(`return (${src})`)()(data),
),
isDA.toString(),
);
await boot(page);
const booted = Date.now();
await expect
.poll(
async () => {
const last = (await daReplies()).at(-1);
return Date.now() - (last ? last.at : booted);
},
{ timeout: 10000, intervals: [250] },
)
.toBeGreaterThan(1000);
await page.evaluate(async () => {
window.__sent.length = 0;
await window.__mobuxView.test.writeData("\x1b[c");
});
await page.waitForTimeout(500);
const replies = (await daReplies()).map(({ data }) => data);
expect(replies).toHaveLength(1);
expect(replies[0]).toMatch(/^\x1b\[\?[\d;]*c$/);
});