const { test, expect, sterkOnly } = 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-smoke";
const TMUX_CMD = process.env.MOBUX_TEST_TMUX || "tmux -L mobux-test";
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 = (args) => execSync(`${TMUX_CMD} ${args}`, { stdio: "pipe" });
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`);
tmux(
`new-window -t ${SESSION} ${SHELL_ENV} -n second "sh -c 'while true; do sleep 60; done'"`,
);
tmux(`select-window -t ${SESSION}:0`);
execSync("sleep 0.3");
});
test.afterAll(() => {
try {
tmux(`kill-session -t ${SESSION}`);
} catch (_) {}
});
test("index loads", async ({ page }) => {
await page.goto(`${BASE}/app#/`);
await expect(page).toHaveTitle(/Mobux/);
});
test("sessionRow pins /app#/s/<host>/<name> when a peer is selected, plain when not", async ({
page,
}) => {
await page.goto(`${BASE}/app#/`);
await page.waitForFunction(() => typeof window.MobuxMesh !== "undefined", {
timeout: 8000,
});
await page.waitForSelector(".session-item", { timeout: 8000 });
await page.evaluate(() => window.MobuxMesh.setPeer(""));
await Promise.all([
page.waitForURL(/\/app#\/s\//, { timeout: 5000 }),
page.locator(".session-item").first().click(),
]);
expect(page.url()).toMatch(/\/app#\/s\/[^/]+$/);
await page.goto(`${BASE}/app#/`);
await page.waitForFunction(() => typeof window.MobuxMesh !== "undefined", {
timeout: 8000,
});
await page.waitForSelector(".session-item", { timeout: 8000 });
await page.evaluate(() => {
window.MobuxMesh.setPeer("box:8443");
window.MobuxMesh.setPeerCred("box:8443", "u", "x");
});
await Promise.all([
page.waitForURL(/\/app#\/s\//, { timeout: 5000 }),
page.locator(".session-item").first().click(),
]);
expect(page.url()).toMatch(/\/app#\/s\/box%3A8443\//);
});
test("sessions API works", async ({ page }) => {
const res = await page.request.get(`${BASE}/api/sessions`);
expect(res.ok()).toBeTruthy();
const sessions = await res.json();
expect(sessions.length).toBeGreaterThan(0);
});
test("create rejects session names with tmux-unsafe characters", async ({
page,
}) => {
for (const name of ["my.app", "a:b"]) {
const res = await page.request.post(`${BASE}/api/sessions`, {
data: { name },
});
expect(res.status(), `"${name}" must be rejected, not mangled`).toBe(400);
}
const ok = await page.request.post(`${BASE}/api/sessions`, {
data: { name: "regress_dot" },
});
expect(ok.status()).toBe(200);
const sessions = await (
await page.request.get(`${BASE}/api/sessions`)
).json();
expect(sessions.some((s) => s.name === "regress_dot")).toBeTruthy();
await page.request.post(`${BASE}/api/sessions/regress_dot/kill`);
});
test("terminal renders and connects", async ({ page }) => {
await page.goto(`${BASE}/app#/s/${SESSION}`);
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForFunction(
() => window.__mobuxView?.test?.wsReady?.() === true,
{ timeout: 5000 },
);
await page.waitForTimeout(500);
await expect(page.locator(".sterk-viewport, .xterm-viewport")).toBeVisible({
timeout: 5000,
});
await expect(page.locator("#touchOverlay")).toBeAttached();
await page.waitForFunction(() => window.__mobuxView.test.bufferLength() > 0, {
timeout: 5000,
});
});
test("scroll works via touch gesture", async ({ page }) => {
await page.goto(`${BASE}/app#/s/${SESSION}`);
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForFunction(
() => window.__mobuxView?.test?.wsReady?.() === true,
{ timeout: 15000 },
);
await page.waitForTimeout(500);
await page.evaluate(() =>
window.__mobuxView.test.injectLines(300, "scrollseed"),
);
await page.waitForFunction(
() => window.__mobuxView.test.bufferLength() > 200,
{ timeout: 20000 },
);
await page.evaluate(() => window.__mobuxView.test.scrollToBottom());
await page.waitForTimeout(300);
await page.evaluate(() => window.__mobuxView.test.scrollToBottom());
const yBefore = await page.evaluate(() =>
window.__mobuxView.test.viewportY(),
);
expect(yBefore).toBeGreaterThan(0);
await page.evaluate(() => {
const overlay = document.getElementById("touchOverlay");
if (!overlay) return;
overlay.style.pointerEvents = "auto";
function fire(type, x, y) {
const t = new Touch({
identifier: 1,
target: overlay,
clientX: x,
clientY: y,
pageX: x,
pageY: y,
});
overlay.dispatchEvent(
new TouchEvent(type, {
touches: type === "touchend" ? [] : [t],
changedTouches: [t],
bubbles: true,
cancelable: true,
}),
);
}
fire("touchstart", 200, 300);
for (let i = 1; i <= 10; i++) fire("touchmove", 200, 300 + i * 20);
fire("touchend", 200, 500);
});
await expect
.poll(
async () =>
await page.evaluate(() => window.__mobuxView.test.viewportY()),
{ timeout: 2000 },
)
.toBeLessThan(yBefore);
});
test("swipe left/right switches tmux windows", async ({ page }) => {
const session = SESSION;
const panesBefore = await (
await page.request.get(`${BASE}/api/sessions/${session}/panes`)
).json();
if (panesBefore.length < 2) {
test.skip(true, "Need 2+ windows");
return;
}
const initialActive = panesBefore.find((p) => p.active)?.index;
const nextRes = await page.request.post(
`${BASE}/api/sessions/${session}/command`,
{
data: { command: "next-window" },
},
);
expect(nextRes.ok()).toBeTruthy();
await page.waitForTimeout(300);
const panesAfterNext = await (
await page.request.get(`${BASE}/api/sessions/${session}/panes`)
).json();
const afterNextActive = panesAfterNext.find((p) => p.active)?.index;
expect(afterNextActive).not.toBe(initialActive);
const prevRes = await page.request.post(
`${BASE}/api/sessions/${session}/command`,
{
data: { command: "prev-window" },
},
);
expect(prevRes.ok()).toBeTruthy();
await page.waitForTimeout(300);
const panesAfterPrev = await (
await page.request.get(`${BASE}/api/sessions/${session}/panes`)
).json();
const afterPrevActive = panesAfterPrev.find((p) => p.active)?.index;
expect(afterPrevActive).toBe(initialActive);
});
test("window switching works via command API", async ({ page }) => {
const session = SESSION;
const panesBefore = await (
await page.request.get(`${BASE}/api/sessions/${session}/panes`)
).json();
if (panesBefore.length < 2) {
test.skip(true, "Need 2+ windows");
return;
}
const initialActive = panesBefore.find((p) => p.active)?.index;
const nextRes = await page.request.post(
`${BASE}/api/sessions/${session}/command`,
{
data: { command: "next-window" },
},
);
expect(nextRes.ok()).toBeTruthy();
await page.waitForTimeout(300);
const panesAfterNext = await (
await page.request.get(`${BASE}/api/sessions/${session}/panes`)
).json();
const afterNextActive = panesAfterNext.find((p) => p.active)?.index;
expect(afterNextActive).not.toBe(initialActive);
const prevRes = await page.request.post(
`${BASE}/api/sessions/${session}/command`,
{
data: { command: "prev-window" },
},
);
expect(prevRes.ok()).toBeTruthy();
await page.waitForTimeout(300);
const panesAfterPrev = await (
await page.request.get(`${BASE}/api/sessions/${session}/panes`)
).json();
const afterPrevActive = panesAfterPrev.find((p) => p.active)?.index;
expect(afterPrevActive).toBe(initialActive);
});
test("URLs in terminal output are tappable", async ({ page }, testInfo) => {
sterkOnly(test, testInfo);
await page.goto(`${BASE}/app#/s/${SESSION}`);
await page.waitForFunction(
() => {
const vp = document.querySelector(".ace_scroller");
return vp && vp.scrollHeight > 100;
},
{ timeout: 5000 },
);
await page.waitForFunction(
() => window.__mobuxView?.test?.wsReady?.() === true,
{ timeout: 15000 },
);
await page.waitForTimeout(300);
await page.evaluate(() => document.querySelector(".ace_text-input").focus());
await page.keyboard.type("clear");
await page.keyboard.press("Enter");
await page.waitForTimeout(500);
await page.keyboard.type("echo https://example.com");
await page.keyboard.press("Enter");
await page.waitForFunction(
() => {
const rows = document.querySelector(".ace_text-layer");
return rows?.textContent?.includes("https://example.com") ?? false;
},
{ timeout: 20000 },
);
const hasUrl = await page.evaluate(() => {
const rows = document.querySelector(".ace_text-layer");
return rows?.textContent?.includes("https://example.com") ?? false;
});
expect(hasUrl).toBe(true);
const detected = await page.evaluate(() => {
const termEl = document.getElementById("terminal");
const rows = termEl?.querySelector(".ace_text-layer");
if (!rows) return false;
const rowDivs = rows.querySelectorAll("div");
for (const div of rowDivs) {
const text = div.textContent || "";
if (text.includes("https://example.com")) {
const match = text.match(/https?:\/\/[^\s)"'>]+/);
return match ? match[0] : false;
}
}
return false;
});
expect(detected).toContain("https://example.com");
});
test("external links: anchor-click in regular browser, intent:// in TWA", async ({
page,
}) => {
await page.goto(`${BASE}/app#/s/${SESSION}`);
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForFunction(
() => typeof window.__mobuxOpenExternal === "function",
{ timeout: 5000 },
);
const nonTwaResult = await page.evaluate(async () => {
const url = "https://example.com/regular-browser-test";
let anchorTarget = null;
let anchorRel = null;
let anchorHref = null;
let windowOpenCalled = false;
let locationAssigned = null;
const origWindowOpen = window.open;
const origLocationAssign = window.location.assign;
window.open = (...args) => {
windowOpenCalled = true;
return null;
};
window.location.assign = (url) => {
locationAssigned = url;
};
const onClick = (e) => {
const a = e.target.closest("a");
if (!a) return;
anchorTarget = a.target;
anchorRel = a.rel;
anchorHref = a.href;
e.preventDefault();
};
document.addEventListener("click", onClick, true);
try {
window.__mobuxOpenExternal(url);
} finally {
document.removeEventListener("click", onClick, true);
window.open = origWindowOpen;
window.location.assign = origLocationAssign;
}
return {
anchorTarget,
anchorRel,
anchorHref,
windowOpenCalled,
locationAssigned,
};
});
expect(nonTwaResult.anchorHref).toBe(
"https://example.com/regular-browser-test",
);
expect(nonTwaResult.anchorTarget).toBe("_blank");
expect(nonTwaResult.anchorRel).toContain("noopener");
expect(nonTwaResult.anchorRel).toContain("noreferrer");
expect(nonTwaResult.windowOpenCalled).toBe(false);
expect(nonTwaResult.locationAssigned).toBeNull();
const twaResult = await page.evaluate(async () => {
const url = "https://example.com/twa-test";
Object.defineProperty(document, "referrer", {
configurable: true,
get: () => "android-app://io.github.mvhenten.mobux",
});
let navigatedToUrl = null;
let anchorClicked = false;
const origNavigate = window.__mobuxNavigateToUrl;
window.__mobuxNavigateToUrl = (url) => {
navigatedToUrl = url;
};
const onClick = (e) => {
anchorClicked = true;
e.preventDefault();
};
document.addEventListener("click", onClick, true);
try {
window.__mobuxOpenExternal(url);
} finally {
document.removeEventListener("click", onClick, true);
window.__mobuxNavigateToUrl = origNavigate;
Object.defineProperty(document, "referrer", {
configurable: true,
get: () => "",
});
}
return { navigatedToUrl, anchorClicked };
});
expect(twaResult.navigatedToUrl).toBeTruthy();
expect(twaResult.navigatedToUrl).toContain("intent://");
expect(twaResult.navigatedToUrl).toContain(
"action=android.intent.action.VIEW",
);
expect(twaResult.navigatedToUrl).toContain("scheme=https");
expect(twaResult.navigatedToUrl).toContain("S.browser_fallback_url=");
expect(twaResult.navigatedToUrl).toContain("example.com/twa-test");
expect(twaResult.anchorClicked).toBe(false);
});
test("reader view renders buffer text", async ({ page }) => {
await page.goto(`${BASE}/app#/s/${SESSION}`);
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForTimeout(800);
await page.evaluate(() =>
window.__mobuxView.test.inject("MOBUX_READER_MARKER_42\n"),
);
await page.evaluate(() => window.__mobuxView.swap("reader"));
await expect
.poll(async () => (await page.locator("#reader").textContent()) || "", {
timeout: 3000,
})
.toContain("MOBUX_READER_MARKER_42");
await expect(page.locator("#reader")).toBeVisible();
await expect(page.locator("#terminal")).toBeHidden();
await page.evaluate(() => window.__mobuxView.swap("xterm"));
await page.waitForTimeout(100);
await expect(page.locator("#terminal")).toBeVisible();
await expect(page.locator("#reader")).toBeHidden();
});
test("reader view live-updates on new output", async ({ page }) => {
await page.goto(`${BASE}/app#/s/${SESSION}`);
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForTimeout(800);
await page.evaluate(() => window.__mobuxView.swap("reader"));
await page.waitForTimeout(150);
await page.evaluate(() =>
window.__mobuxView.test.inject("MOBUX_LIVE_PROBE_99\n"),
);
await expect
.poll(async () => (await page.locator("#reader").textContent()) || "", {
timeout: 3000,
})
.toContain("MOBUX_LIVE_PROBE_99");
await page.evaluate(() => window.__mobuxView.swap("xterm"));
});
test("long-press menu toggles reader view", async ({ page }, testInfo) => {
const renderer = testInfo.project.use && testInfo.project.use.renderer;
await page.addInitScript((r) => {
try {
localStorage.clear();
if (r === "sterk") localStorage.setItem("mobux:renderer", "sterk");
} catch (_) {}
}, renderer);
await page.goto(`${BASE}/app#/s/${SESSION}`);
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForFunction(
() => window.__mobuxView?.test?.wsReady?.() === true,
{ timeout: 5000 },
);
await page.waitForFunction(() => window.__mobuxView.test.bufferLength() > 0, {
timeout: 5000,
});
await expect(page.locator("#terminal")).toBeVisible();
await expect(page.locator("#viewToggleBtn")).toHaveText("📖");
await page.evaluate(() =>
document.getElementById("inputBar").classList.remove("hidden"),
);
await page.locator("#viewToggleBtn").scrollIntoViewIfNeeded();
await page.locator("#viewToggleBtn").click({ force: true });
await expect(page.locator("#reader")).toBeVisible();
await expect(page.locator("#terminal")).toBeHidden();
await expect(page.locator("#viewToggleBtn")).toHaveText("▣");
await page.locator("#viewToggleBtn").click({ force: true });
await expect(page.locator("#terminal")).toBeVisible();
await expect(page.locator("#reader")).toBeHidden();
await expect(page.locator("#viewToggleBtn")).toHaveText("📖");
});
test("panes API returns window id", async ({ page }) => {
const panes = await (
await page.request.get(`${BASE}/api/sessions/${SESSION}/panes`)
).json();
expect(panes.length).toBeGreaterThan(0);
for (const p of panes) {
expect(p.id).toMatch(/^@\d+$/);
expect(typeof p.index).toBe("string");
}
});
async function fireTouch(page, selector, type, x, y) {
await page.evaluate(
({ selector, type, x, y }) => {
const el = document.querySelector(selector);
const t = new Touch({
identifier: 1,
target: el,
clientX: x,
clientY: y,
pageX: x,
pageY: y,
});
el.dispatchEvent(
new TouchEvent(type, {
touches: type === "touchend" ? [] : [t],
changedTouches: [t],
bubbles: true,
cancelable: true,
}),
);
},
{ selector, type, x, y },
);
}
test("swipe-up from bottom edge opens the command menu", async ({ page }) => {
await page.goto(`${BASE}/app#/s/${SESSION}`);
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForTimeout(500);
await page.evaluate(() => {
document.getElementById("touchOverlay").style.pointerEvents = "auto";
document.getElementById("cmdPickList").classList.remove("visible");
});
const vh = await page.evaluate(() => window.innerHeight);
const xMid = await page.evaluate(() => window.innerWidth / 2);
await fireTouch(page, "#touchOverlay", "touchstart", xMid, vh - 20);
await fireTouch(page, "#touchOverlay", "touchmove", xMid, vh - 60);
await fireTouch(page, "#touchOverlay", "touchmove", xMid, vh - 100);
await fireTouch(page, "#touchOverlay", "touchend", xMid, vh - 100);
await page.waitForTimeout(150);
await expect(page.locator("#cmdPickList")).toHaveClass(/visible/);
});
test("mid-screen upward drag does not trigger the command menu", async ({
page,
}) => {
await page.goto(`${BASE}/app#/s/${SESSION}`);
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForTimeout(500);
await page.evaluate(() => {
document.getElementById("touchOverlay").style.pointerEvents = "auto";
document.getElementById("cmdPickList").classList.remove("visible");
});
const vh = await page.evaluate(() => window.innerHeight);
const xMid = await page.evaluate(() => window.innerWidth / 2);
await fireTouch(
page,
"#touchOverlay",
"touchstart",
xMid,
Math.round(vh / 2),
);
await fireTouch(
page,
"#touchOverlay",
"touchmove",
xMid,
Math.round(vh / 2) - 40,
);
await fireTouch(
page,
"#touchOverlay",
"touchmove",
xMid,
Math.round(vh / 2) - 100,
);
await fireTouch(
page,
"#touchOverlay",
"touchend",
xMid,
Math.round(vh / 2) - 100,
);
await page.waitForTimeout(150);
await expect(page.locator("#cmdPickList")).not.toHaveClass(/visible/);
});
test("reader view disables terminal touch overlay", async ({ page }) => {
await page.goto(`${BASE}/app#/s/${SESSION}`);
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForTimeout(800);
await page.evaluate(() => window.__mobuxView.swap("reader"));
await page.waitForTimeout(200);
const overlayPE = await page.evaluate(
() =>
getComputedStyle(document.getElementById("touchOverlay")).pointerEvents,
);
expect(overlayPE).toBe("none");
await page.evaluate(() => window.__mobuxView.swap("xterm"));
await page.waitForTimeout(150);
const overlayPEAfter = await page.evaluate(
() =>
getComputedStyle(document.getElementById("touchOverlay")).pointerEvents,
);
expect(overlayPEAfter).toBe("auto");
});
test("reader view toggle button in input ribbon flips back to xterm", async ({
page,
}) => {
await page.goto(`${BASE}/app#/s/${SESSION}`);
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForTimeout(800);
await page.evaluate(() => window.__mobuxView.test.injectLines(120, "rl"));
await page.evaluate(() => window.__mobuxView.swap("reader"));
await page.waitForTimeout(250);
await page.evaluate(() =>
document.getElementById("inputBar").classList.remove("hidden"),
);
await page.locator("#viewToggleBtn").scrollIntoViewIfNeeded();
await page.locator("#viewToggleBtn").click({ force: true });
await expect
.poll(async () => await page.evaluate(() => window.__mobuxView.current), {
timeout: 1500,
})
.toBe("xterm");
});
const RED = "\x1b[31m";
const GREEN = "\x1b[32m";
const BOLD = "\x1b[1m";
const RESET = "\x1b[0m";
async function injectRaw(page, str) {
await page.evaluate((s) => window.__mobuxView.test.inject(s), str);
}
async function blockSummary(page) {
return await page.evaluate(() => {
const blocks = document.querySelectorAll("#reader .rb");
return Array.from(blocks).map((b) => ({
classes: Array.from(b.classList).filter((c) => c !== "rb"),
text: (b.textContent || "").trim().slice(0, 80),
}));
});
}
test("reader colours preserved (red + green spans)", async ({ page }) => {
await page.goto(`${BASE}/app#/s/${SESSION}`);
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForTimeout(800);
await page.evaluate(() => window.__mobuxView.swap("reader"));
await page.waitForTimeout(150);
await injectRaw(page, `${RED}- removed${RESET}\n${GREEN}+ added${RESET}\n`);
await page.waitForTimeout(200);
const colours = await page.evaluate(() => {
const spans = document.querySelectorAll("#reader span");
return Array.from(spans)
.map((s) => ({ t: s.textContent, c: s.style.color }))
.filter((s) => s.t && s.c);
});
const reds = colours.filter((c) =>
/var\(--ansi-1\)|rgb\(204|cc6666/.test(c.c),
);
const greens = colours.filter((c) => /var\(--ansi-2\)|b5bd68/.test(c.c));
expect(reds.length).toBeGreaterThan(0);
expect(greens.length).toBeGreaterThan(0);
expect(reds.some((r) => r.t.includes("removed"))).toBe(true);
expect(greens.some((g) => g.t.includes("added"))).toBe(true);
});
test("reader detects prompt, header, rule, code blocks", async ({ page }) => {
await page.goto(`${BASE}/app#/s/${SESSION}`);
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForTimeout(800);
await page.evaluate(() => window.__mobuxView.swap("reader"));
await page.waitForTimeout(150);
await injectRaw(
page,
[
"~/dev (main) $",
"[Context]",
"\u2500".repeat(40),
"```",
" fn hello() {}",
"```",
"plain prose line.",
].join("\n") + "\n",
);
await page.waitForTimeout(250);
const summary = await blockSummary(page);
const types = summary.map((b) => b.classes.join(" "));
expect(types.some((t) => t.includes("rb-prompt"))).toBe(true);
expect(types.some((t) => t.includes("rb-header"))).toBe(true);
expect(types.some((t) => t.includes("rb-rule"))).toBe(true);
expect(types.some((t) => t.includes("rb-code"))).toBe(true);
expect(types.some((t) => t.includes("rb-text"))).toBe(true);
const codeText = await page.locator("#reader .rb-code").textContent();
expect(codeText).toContain("fn hello()");
expect(codeText).not.toContain("```");
});
test("OSC 133 ; A marks lines without a sigil as prompts", async ({ page }) => {
await page.goto(`${BASE}/app#/s/${SESSION}`);
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForTimeout(800);
await page.evaluate(() => window.__mobuxView.swap("reader"));
await page.waitForTimeout(150);
await injectRaw(
page,
"\x1b]133;A\x07my-shell-prompt-no-sigil\nrun output line\n",
);
await page.waitForTimeout(250);
const summary = await blockSummary(page);
const promptHit = summary.find(
(b) =>
b.classes.includes("rb-prompt") &&
b.text.includes("my-shell-prompt-no-sigil"),
);
expect(promptHit).toBeTruthy();
const hintHidden = await page.evaluate(() => {
const el = document.querySelector(".reader-osc-hint");
return !el || el.hidden;
});
expect(hintHidden).toBe(true);
});
test("OSC 133 works out of the box for mobux-created sessions (no installer)", async ({
page,
}) => {
const OOTB_SESSION = `${SESSION}-ootb`;
const OOTB_HOME = "/tmp/mobux-ootb-home";
execSync(`rm -rf ${OOTB_HOME} && mkdir -p ${OOTB_HOME}`);
expect(
execSync(`grep -rl 'mobux OSC 133' ${OOTB_HOME} 2>/dev/null || true`)
.toString()
.trim(),
).toBe("");
try {
tmux(`kill-session -t ${OOTB_SESSION}`);
} catch (_) {}
const create = await page.request.post(`${BASE}/api/sessions`, {
data: { name: OOTB_SESSION },
});
expect(create.ok()).toBeTruthy();
try {
await page.goto(`${BASE}/app#/s/${OOTB_SESSION}`);
await page.waitForFunction(
() => typeof window.__mobuxView !== "undefined",
{ timeout: 5000 },
);
await page.waitForFunction(
() => window.__mobuxView?.test?.wsReady?.() === true,
{ timeout: 5000 },
);
const before = await page.evaluate(() =>
window.__mobuxView.test.oscDetected(),
);
expect(before).toBe(false);
tmux(`send-keys -t ${OOTB_SESSION} "" Enter`);
await page.waitForFunction(
() => window.__mobuxView.test.oscDetected() === true,
{ timeout: 5000 },
);
const after = await page.evaluate(() =>
window.__mobuxView.test.oscDetected(),
);
expect(after).toBe(true);
const homeAfter = execSync(
`grep -rl 'mobux OSC 133' ${OOTB_HOME} 2>/dev/null || true`,
)
.toString()
.trim();
expect(homeAfter).toBe("");
} finally {
try {
tmux(`kill-session -t ${OOTB_SESSION}`);
} catch (_) {}
}
});
test("OSC 133 ; A wrapped in tmux DCS passthrough reaches libterm", async ({
page,
}) => {
const PT_SESSION = `${SESSION}-osc133-pt`;
try {
tmux(`kill-session -t ${PT_SESSION}`);
} catch (_) {}
tmux(
`new-session -d -s ${PT_SESSION} ${SHELL_ENV} "bash --norc --noprofile"`,
);
tmux(`send-keys -t ${PT_SESSION} "PS1=':: '" Enter`);
tmux(`send-keys -t ${PT_SESSION} "clear" Enter`);
execSync("sleep 0.3");
try {
await page.goto(`${BASE}/app#/s/${PT_SESSION}`);
await page.waitForFunction(
() => typeof window.__mobuxView !== "undefined",
{ timeout: 5000 },
);
await page.waitForFunction(
() => window.__mobuxView?.test?.wsReady?.() === true,
{
timeout: 5000,
},
);
let allowPassthroughOn = false;
for (let i = 0; i < 50; i++) {
const v = execSync(
`${TMUX_CMD} show-option -gv allow-passthrough 2>/dev/null || true`,
)
.toString()
.trim();
if (v === "on") {
allowPassthroughOn = true;
break;
}
execSync("sleep 0.1");
}
expect(allowPassthroughOn).toBe(true);
const before = await page.evaluate(() =>
window.__mobuxView.test.oscDetected(),
);
expect(before).toBe(false);
const wrapped = "printf '\\ePtmux;\\e\\e]133;A\\a\\e\\\\\\n'";
tmux(`send-keys -t ${PT_SESSION} "${wrapped}" Enter`);
await page.waitForFunction(
() => window.__mobuxView.test.oscDetected() === true,
{ timeout: 8000 },
);
const after = await page.evaluate(() =>
window.__mobuxView.test.oscDetected(),
);
expect(after).toBe(true);
await page.evaluate(() => window.__mobuxView.swap("reader"));
await page.waitForTimeout(200);
const hintHidden = await page.evaluate(() => {
const el = document.querySelector(".reader-osc-hint");
return !el || el.hidden;
});
expect(hintHidden).toBe(true);
} finally {
try {
tmux(`kill-session -t ${PT_SESSION}`);
} catch (_) {}
}
});
test("reader strips trailing default-attr whitespace from lines", async ({
page,
}) => {
await page.goto(`${BASE}/app#/s/${SESSION}`);
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForTimeout(800);
await page.evaluate(() => window.__mobuxView.swap("reader"));
await page.waitForTimeout(150);
await injectRaw(
page,
"TRAILMARK content \n",
);
await page.waitForTimeout(200);
const trailers = await page.evaluate(() => {
const lines = Array.from(document.querySelectorAll("#reader .rb-line"));
return lines
.map((l) => l.textContent || "")
.filter((t) => t.length > 0 && /[ \t]$/.test(t));
});
expect(trailers).toEqual([]);
});
test("consecutive same-bg lines fuse into a single bubble", async ({
page,
}) => {
await page.goto(`${BASE}/app#/s/${SESSION}`);
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForTimeout(800);
await page.evaluate(() => window.__mobuxView.swap("reader"));
await page.waitForTimeout(150);
const BLUE_BG = "\x1b[44m";
const RESET2 = "\x1b[0m";
await injectRaw(
page,
`\n${BLUE_BG}bubble line one${RESET2}\n` +
`${BLUE_BG}bubble line two${RESET2}\n` +
`${BLUE_BG}bubble line three${RESET2}\n` +
`plain trailing line\n`,
);
await page.waitForFunction(
() =>
Array.from(document.querySelectorAll("#reader .rb-bubble")).some(
(b) => b.querySelectorAll(".rb-bubble-line").length >= 3,
),
{ timeout: 3000 },
);
const bubbles = await page.evaluate(() => {
const els = document.querySelectorAll("#reader .rb-bubble");
return Array.from(els).map((b) => ({
lines: b.querySelectorAll(".rb-bubble-line").length,
text: (b.textContent || "").trim(),
}));
});
const fused = bubbles.find(
(b) =>
b.text.includes("bubble line one") &&
b.text.includes("bubble line three"),
);
expect(fused).toBeTruthy();
expect(fused.lines).toBeGreaterThanOrEqual(3);
});
test("terminal picks readable fg by bg luminance when fg is default", async ({
page,
}, testInfo) => {
sterkOnly(test, testInfo);
test.setTimeout(60000);
await page.goto(`${BASE}/app#/s/${SESSION}`);
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForFunction(
() => window.__mobuxView?.test?.wsReady?.() === true,
{ timeout: 15000 },
);
await page.waitForTimeout(500);
await page.evaluate(() => window.__mobuxView.swap("xterm"));
await page.waitForTimeout(500); await page.waitForFunction(
() => {
const term = document.getElementById("terminal");
const aceLines = document.querySelectorAll(".ace_line");
return term && !term.classList.contains("hidden") && aceLines.length > 0;
},
{ timeout: 10000 },
);
await injectRaw(
page,
"\n\x1b[42mGREEN_BG_DEFAULT_FG\x1b[0m\n" +
"\x1b[46mCYAN_BG_DEFAULT_FG\x1b[0m\n" +
"\x1b[40mBLACK_BG_DEFAULT_FG\x1b[0m\n" +
"\x1b[44mBLUE_BG_DEFAULT_FG\x1b[0m\n" +
"\x1b[33;44mYELLOW_FG_BLUE_BG\x1b[0m\n",
);
await page.evaluate(() => {
const ed = window.__sterk?._sterk?.renderer?.getEditor?.();
if (ed) ed.gotoLine(ed.session.getLength(), 0, false);
});
await page.waitForFunction(
() => {
const text = document.body.textContent || "";
return (
text.includes("GREEN_BG_DEFAULT_FG") &&
text.includes("CYAN_BG_DEFAULT_FG") &&
text.includes("BLACK_BG_DEFAULT_FG") &&
text.includes("BLUE_BG_DEFAULT_FG") &&
text.includes("YELLOW_FG_BLUE_BG") &&
document.querySelector('[class*="ace_sterk-bg-"]') !== null
);
},
{ timeout: 25000 },
);
const hexToRgb = (hex) => {
const h = hex.replace("#", "");
return [
parseInt(h.substring(0, 2), 16),
parseInt(h.substring(2, 4), 16),
parseInt(h.substring(4, 6), 16),
];
};
const lum = (rgbArr) => {
if (!rgbArr) return null;
const lin = (c) => {
const v = c / 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
};
return (
0.2126 * lin(rgbArr[0]) +
0.7152 * lin(rgbArr[1]) +
0.0722 * lin(rgbArr[2])
);
};
const styled = await page.evaluate(() => {
const lines = Array.from(document.querySelectorAll(".ace_line"));
const palette = window.__sterk?.options?.theme?.palette || [];
const theme = window.__sterk?.options?.theme || {};
const defaultFg = theme.foreground || "#c5c8c6";
const defaultBg = theme.background || "#1e1e1e";
const markers = [
"GREEN_BG_DEFAULT_FG",
"CYAN_BG_DEFAULT_FG",
"BLACK_BG_DEFAULT_FG",
"BLUE_BG_DEFAULT_FG",
"YELLOW_FG_BLUE_BG",
];
const result = {};
for (const marker of markers) {
const line = lines.find((l) => (l.textContent || "").includes(marker));
if (!line) continue;
const sterkSpan = Array.from(line.querySelectorAll("span")).find((span) =>
span.className.includes("sterk-"),
);
if (!sterkSpan) continue;
const cls = sterkSpan.className;
let fgColor = defaultFg;
let bgColor = defaultBg;
const fgMatch = cls.match(/sterk-fg-(\d+)/);
if (fgMatch) {
const idx = parseInt(fgMatch[1], 10);
fgColor = palette[idx] || defaultFg;
}
const bgMatch = cls.match(/sterk-bg-(\d+)/);
if (bgMatch) {
const idx = parseInt(bgMatch[1], 10);
bgColor = palette[idx] || defaultBg;
}
result[marker] = { marker, color: fgColor, bg: bgColor };
}
return result;
});
const find = (marker) => styled[marker];
const green = find("GREEN_BG_DEFAULT_FG");
const cyan = find("CYAN_BG_DEFAULT_FG");
const black = find("BLACK_BG_DEFAULT_FG");
const blue = find("BLUE_BG_DEFAULT_FG");
const yel = find("YELLOW_FG_BLUE_BG");
for (const s of [green, cyan, black, blue, yel]) {
expect(s).toBeTruthy();
expect(s.color).toBeTruthy();
expect(s.bg).toBeTruthy();
}
for (const s of [green, cyan]) {
const bgL = lum(hexToRgb(s.bg));
const fgL = lum(hexToRgb(s.color));
expect(bgL).toBeGreaterThan(0.15);
}
for (const s of [black, blue]) {
const bgL = lum(hexToRgb(s.bg));
expect(bgL).toBeLessThan(0.4);
}
const yfgRgb = hexToRgb(yel.color);
const ybgRgb = hexToRgb(yel.bg);
expect(yfgRgb).toBeTruthy();
expect(ybgRgb).toBeTruthy();
expect(yfgRgb[0]).toBeGreaterThan(200);
expect(yfgRgb[1]).toBeGreaterThan(150);
expect(yfgRgb[2]).toBeLessThan(200);
});
test("terminal uses the muted base16 palette, not Tango defaults", async ({
page,
}, testInfo) => {
sterkOnly(test, testInfo);
await page.goto(`${BASE}/app#/s/${SESSION}`);
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForTimeout(800);
const palette = await page.evaluate(() => {
const sterk = window.__sterk;
if (!sterk || !sterk.options || !sterk.options.theme) return null;
return {
base16: sterk.options.theme.palette || [],
scrollback: sterk.options.scrollback,
};
});
expect(palette).toBeTruthy();
expect(palette.base16[2]?.toLowerCase()).toBe("#b5bd68");
expect(palette.base16[10]?.toLowerCase()).toBe("#98c379");
expect(palette.base16[14]?.toLowerCase()).toBe("#56b6c2");
expect(palette.scrollback).toBe(10000);
});
test("reader supports synthetic scrolling when content overflows", async ({
page,
}) => {
await page.goto(`${BASE}/app#/s/${SESSION}`);
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForTimeout(800);
await page.evaluate(() => window.__mobuxView.swap("reader"));
await page.waitForTimeout(150);
const big = Array.from({ length: 200 }, (_, i) => `line ${i} content`).join(
"\n",
);
await injectRaw(page, big + "\n");
await page.waitForTimeout(300);
const max = await page.evaluate(() =>
window.__mobuxView.test.readerMaxScroll(),
);
expect(max).toBeGreaterThan(0);
const moved = await page.evaluate(() => {
window.__mobuxView.test.readerScrollBy(-1e6);
const top = window.__mobuxView.test.readerScrollY();
window.__mobuxView.test.readerScrollBy(500);
return { top, mid: window.__mobuxView.test.readerScrollY() };
});
expect(moved.top).toBe(0);
expect(moved.mid).toBeGreaterThan(0);
});
test.skip("reader status bar stays filled after a tmux window switch", async ({
page,
}) => {
await page.goto(`${BASE}/app#/s/${SESSION}`);
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForTimeout(800);
await page.evaluate(() => window.__mobuxView.swap("reader"));
await expect
.poll(
async () =>
await page.evaluate(() => window.__mobuxView.test.bufferLength()),
{ timeout: 5000 },
)
.toBeGreaterThan(1);
await expect
.poll(
async () =>
await page.evaluate(() => ({
sbH: window.__mobuxView.test.statusBarOffsetHeight(),
filled: window.__mobuxView.test.statusBarFilled(),
})),
{ timeout: 8000 },
)
.toMatchObject({ filled: true });
await page.evaluate(() => window.__mobuxView.test.switchWindow("next"));
await page.waitForTimeout(1500);
await page.evaluate(() => window.__mobuxView.test.switchWindow("prev"));
await expect
.poll(
async () =>
await page.evaluate(() => ({
sbH: window.__mobuxView.test.statusBarOffsetHeight(),
filled: window.__mobuxView.test.statusBarFilled(),
})),
{ timeout: 8000 },
)
.toMatchObject({ filled: true });
});
test("view preference persists per window", async ({ page }) => {
const session = SESSION;
const panes = await (
await page.request.get(`${BASE}/api/sessions/${session}/panes`)
).json();
const activeId = panes.find((p) => p.active).id;
await page.goto(`${BASE}/app#/s/${session}`);
await page.evaluate(() => {
try {
localStorage.clear();
} catch (_) {}
});
await page.reload();
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForFunction(
() => window.__mobuxView?.test?.wsReady?.() === true,
{ timeout: 5000 },
);
await page.waitForTimeout(500);
await page.evaluate(() => window.__mobuxView.swap("reader"));
await page.waitForTimeout(150);
const stored = await page.evaluate(
({ session, id }) => ({
perWindow: localStorage.getItem(`mobux.view.${session}.${id}`),
default: localStorage.getItem("mobux.view.default"),
}),
{ session, id: activeId },
);
expect(stored.perWindow).toBe("reader");
expect(stored.default).toBe("reader");
await page.reload();
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await expect
.poll(async () => await page.evaluate(() => window.__mobuxView.current), {
timeout: 3000,
})
.toBe("reader");
});
async function bootReader(page) {
await page.goto(`${BASE}/app#/s/${SESSION}`);
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForFunction(
() => window.__mobuxView?.test?.wsReady?.() === true,
{ timeout: 15000 },
);
await page.waitForTimeout(300);
await page.evaluate(() => window.__mobuxView.swap("xterm"));
await page.waitForTimeout(50);
await page.evaluate(() => window.__mobuxView.swap("reader"));
await page.waitForTimeout(150);
}
async function fillReader(page, n = 300, prefix = "svline") {
await page.evaluate(
(args) => window.__mobuxView.test.injectLines(args.n, args.prefix),
{ n, prefix },
);
await page.waitForFunction(
() => window.__mobuxView.test.readerMaxScroll() > 0,
{ timeout: 15000 },
);
}
function readTransformY(page) {
return page.evaluate(() => {
const el = document.querySelector("#reader .reader-inner");
if (!el) return null;
const t = el.style.transform || "";
const m = t.match(/translate3d\(\s*0(?:px)?\s*,\s*(-?[\d.]+)px/);
return m ? parseFloat(m[1]) : null;
});
}
test("synthetic viewport: translate3d transform reflects scrollY", async ({
page,
}) => {
await bootReader(page);
await fillReader(page);
await page.evaluate(() => window.__mobuxView.test.readerScrollBy(-9e9));
expect(await readTransformY(page)).toBe(0);
await page.evaluate(() => window.__mobuxView.test.readerScrollBy(250));
const y = await readTransformY(page);
const sy = await page.evaluate(() => window.__mobuxView.test.readerScrollY());
expect(sy).toBeGreaterThan(0);
expect(y).toBeLessThan(0);
expect(Math.round(-y)).toBe(Math.round(sy));
});
test("synthetic viewport: clamps at 0", async ({ page }) => {
await bootReader(page);
await fillReader(page);
await page.evaluate(() => window.__mobuxView.test.readerScrollBy(-9e9));
const sy = await page.evaluate(() => window.__mobuxView.test.readerScrollY());
expect(sy).toBe(0);
});
test("synthetic viewport: clamps at max with overflowing content", async ({
page,
}) => {
await bootReader(page);
await fillReader(page);
const { sy, max } = await page.evaluate(() => {
window.__mobuxView.test.readerScrollBy(9e9);
return {
sy: window.__mobuxView.test.readerScrollY(),
max: window.__mobuxView.test.readerMaxScroll(),
};
});
expect(max).toBeGreaterThan(0);
expect(sy).toBe(max);
});
test("synthetic viewport: sticky-to-bottom on new output", async ({ page }) => {
await bootReader(page);
await fillReader(page, 200, "sticky");
await page.evaluate(() => window.__mobuxView.test.readerScrollBy(9e9));
const before = await page.evaluate(() => ({
sy: window.__mobuxView.test.readerScrollY(),
max: window.__mobuxView.test.readerMaxScroll(),
}));
expect(before.sy).toBe(before.max);
expect(before.max).toBeGreaterThan(0);
await page.evaluate(async () => {
const renderDone = window.__mobuxView.test.readerAwaitRender();
window.__mobuxView.test.injectLinesPlain(80, "sticky2");
await renderDone;
});
const after = await page.evaluate(() => ({
sy: window.__mobuxView.test.readerScrollY(),
max: window.__mobuxView.test.readerMaxScroll(),
}));
expect(after.max).toBeGreaterThan(0);
expect(after.sy).toBe(after.max);
});
test("synthetic viewport: not sticky when scrolled up", async ({ page }) => {
test.setTimeout(60000);
await bootReader(page);
await fillReader(page, 200, "noscroll");
await page.evaluate(() => window.__mobuxView.test.readerForceScrollTop());
const before = await page.evaluate(() =>
window.__mobuxView.test.readerScrollY(),
);
expect(before).toBe(0);
await page.evaluate(() => window.__mobuxView.test.injectLines(80, "tail"));
await page.waitForFunction(
() => {
const m = window.__mobuxView.test.readerMaxScroll();
const sy = window.__mobuxView.test.readerScrollY();
return m > 200 && sy <= 5;
},
{ timeout: 20000 },
);
const sy = await page.evaluate(() => window.__mobuxView.test.readerScrollY());
expect(sy).toBeGreaterThanOrEqual(0);
expect(sy).toBeLessThanOrEqual(5);
});
test("synthetic viewport: resize changes maxScroll", async ({ page }) => {
await page.setViewportSize({ width: 400, height: 800 });
await bootReader(page);
await fillReader(page, 300, "resz");
const tall = await page.evaluate(() =>
window.__mobuxView.test.readerMaxScroll(),
);
await page.setViewportSize({ width: 400, height: 400 });
await page.waitForFunction(
(prev) => window.__mobuxView.test.readerMaxScroll() > prev,
tall,
{ timeout: 3000 },
);
const shortMax = await page.evaluate(() =>
window.__mobuxView.test.readerMaxScroll(),
);
expect(shortMax).toBeGreaterThan(tall);
await page.setViewportSize({ width: 400, height: 1000 });
await page.waitForFunction(
(prev) => window.__mobuxView.test.readerMaxScroll() < prev,
shortMax,
{ timeout: 3000 },
);
const tallerMax = await page.evaluate(() =>
window.__mobuxView.test.readerMaxScroll(),
);
expect(tallerMax).toBeLessThan(shortMax);
});
test("synthetic viewport: mount/unmount has no duplicate inner", async ({
page,
}) => {
await bootReader(page);
await fillReader(page, 150, "mu");
for (let i = 0; i < 3; i++) {
await page.evaluate(() => window.__mobuxView.swap("xterm"));
await page.waitForTimeout(80);
await page.evaluate(() => window.__mobuxView.swap("reader"));
await page.waitForTimeout(150);
}
const innerCount = await page.locator("#reader .reader-inner").count();
expect(innerCount).toBe(1);
const { sy, max } = await page.evaluate(() => ({
sy: window.__mobuxView.test.readerScrollY(),
max: window.__mobuxView.test.readerMaxScroll(),
}));
expect(sy).toBeGreaterThanOrEqual(0);
expect(sy).toBeLessThanOrEqual(max);
});
test("synthetic viewport: history smoke renders blocks and overflows", async ({
page,
}) => {
await page.goto(`${BASE}/app#/s/${SESSION}`);
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 15000,
});
await page.waitForTimeout(800);
await page.evaluate(() => window.__mobuxView.swap("xterm"));
await page.waitForTimeout(50);
await page.evaluate(() => window.__mobuxView.test.injectLines(200, "hist"));
await page.evaluate(() => window.__mobuxView.swap("reader"));
await page.waitForFunction(
() =>
document.querySelectorAll("#reader .rb-line").length >= 100 &&
window.__mobuxView.test.readerMaxScroll() > 0,
{ timeout: 15000 },
);
const max = await page.evaluate(() =>
window.__mobuxView.test.readerMaxScroll(),
);
expect(max).toBeGreaterThan(0);
const lineCount = await page.locator("#reader .rb-line").count();
expect(lineCount).toBeGreaterThanOrEqual(100);
});
test("synthetic viewport: bubble fusion under translated inner", async ({
page,
}) => {
await bootReader(page);
const BLUE_BG = "\x1b[44m";
const RESET2 = "\x1b[0m";
await page.evaluate((args) => window.__mobuxView.test.inject(args.s), {
s:
`\n${BLUE_BG}sv bubble one${RESET2}\n` +
`${BLUE_BG}sv bubble two${RESET2}\n` +
`${BLUE_BG}sv bubble three${RESET2}\n`,
});
await page.waitForFunction(
() =>
Array.from(document.querySelectorAll("#reader .rb-bubble")).some(
(b) => b.querySelectorAll(".rb-bubble-line").length >= 3,
),
{ timeout: 3000 },
);
const insideInner = await page.evaluate(() => {
const inner = document.querySelector("#reader .reader-inner");
const b = document.querySelector("#reader .rb-bubble");
return !!(inner && b && inner.contains(b));
});
expect(insideInner).toBe(true);
});
test("input bar sits above on-screen keyboard via visualViewport", async ({
page,
}) => {
await page.goto(`${BASE}/app#/s/${SESSION}`);
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForTimeout(500);
await page.setViewportSize({ width: 380, height: 800 });
await page.evaluate(() => {
const bar = document.getElementById("inputBar");
bar.classList.remove("hidden");
const vv = window.visualViewport;
window.__origVVHeight = vv.height;
window.__origVVOffset = vv.offsetTop;
Object.defineProperty(vv, "height", {
configurable: true,
get: () =>
typeof window.__stubVVHeight === "number"
? window.__stubVVHeight
: window.__origVVHeight,
});
Object.defineProperty(vv, "offsetTop", {
configurable: true,
get: () =>
typeof window.__stubVVOffset === "number"
? window.__stubVVOffset
: window.__origVVOffset,
});
});
await page.evaluate(() => {
window.__stubVVHeight = window.innerHeight - 300;
window.__stubVVOffset = 0;
window.visualViewport.dispatchEvent(new Event("resize"));
});
await expect
.poll(async () => await page.evaluate(() => document.body.style.height), {
timeout: 2000,
})
.toMatch(/^\d+(\.\d+)?px$/);
const barBottom = await page.evaluate(() => {
const r = document.getElementById("inputBar").getBoundingClientRect();
return r.bottom;
});
expect(barBottom).toBeLessThanOrEqual(500 + 1);
await page.evaluate(() => {
window.__stubVVHeight = window.innerHeight;
window.__stubVVOffset = 0;
window.visualViewport.dispatchEvent(new Event("resize"));
});
await expect
.poll(async () => await page.evaluate(() => document.body.style.height), {
timeout: 2000,
})
.toBe("");
});
test("input bar does not overlap #terminal when shown", async ({ page }) => {
await page.goto(`${BASE}/app#/s/${SESSION}`);
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForTimeout(500);
await page.setViewportSize({ width: 380, height: 800 });
await page.evaluate(() =>
document.getElementById("inputBar").classList.remove("hidden"),
);
await page.waitForTimeout(50);
const noKb = await page.evaluate(() => {
const t = document.getElementById("terminal").getBoundingClientRect();
const b = document.getElementById("inputBar").getBoundingClientRect();
return { tBottom: t.bottom, bTop: b.top };
});
expect(Math.abs(noKb.tBottom - noKb.bTop)).toBeLessThanOrEqual(1);
await page.evaluate(() => {
const vv = window.visualViewport;
Object.defineProperty(vv, "height", {
configurable: true,
get: () =>
typeof window.__stubVVHeight === "number"
? window.__stubVVHeight
: window.innerHeight,
});
Object.defineProperty(vv, "offsetTop", {
configurable: true,
get: () =>
typeof window.__stubVVOffset === "number" ? window.__stubVVOffset : 0,
});
window.__stubVVHeight = window.innerHeight - 300;
window.__stubVVOffset = 0;
window.visualViewport.dispatchEvent(new Event("resize"));
});
await page.waitForTimeout(50);
const withKb = await page.evaluate(() => {
const t = document.getElementById("terminal").getBoundingClientRect();
const b = document.getElementById("inputBar").getBoundingClientRect();
return { tBottom: t.bottom, bTop: b.top };
});
expect(Math.abs(withKb.tBottom - withKb.bTop)).toBeLessThanOrEqual(1);
});
test("content area shrinks under on-screen keyboard so reader text stays visible", async ({
page,
}) => {
await page.goto(`${BASE}/app#/s/${SESSION}`);
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForTimeout(500);
await page.setViewportSize({ width: 380, height: 800 });
await page.evaluate(() => {
const bar = document.getElementById("inputBar");
bar.classList.remove("hidden");
const vv = window.visualViewport;
window.__origVVHeight = vv.height;
window.__origVVOffset = vv.offsetTop;
Object.defineProperty(vv, "height", {
configurable: true,
get: () =>
typeof window.__stubVVHeight === "number"
? window.__stubVVHeight
: window.__origVVHeight,
});
Object.defineProperty(vv, "offsetTop", {
configurable: true,
get: () =>
typeof window.__stubVVOffset === "number"
? window.__stubVVOffset
: window.__origVVOffset,
});
});
const before = await page.evaluate(() => ({
terminal: document.getElementById("terminal").clientHeight,
bodyHeight: document.body.style.height,
}));
await page.evaluate(() => {
window.__stubVVHeight = window.innerHeight - 300;
window.__stubVVOffset = 0;
window.visualViewport.dispatchEvent(new Event("resize"));
});
await expect
.poll(async () => await page.evaluate(() => document.body.style.height), {
timeout: 2000,
})
.toMatch(/^\d+(\.\d+)?px$/);
const after = await page.evaluate(() => ({
terminal: document.getElementById("terminal").clientHeight,
bodyHeight: document.body.style.height,
}));
expect(after.terminal).toBeLessThan(before.terminal - 250);
await page.evaluate(() => {
window.__stubVVHeight = window.innerHeight;
window.__stubVVOffset = 0;
window.visualViewport.dispatchEvent(new Event("resize"));
});
await expect
.poll(async () => await page.evaluate(() => document.body.style.height), {
timeout: 2000,
})
.toBe("");
});
test("reader re-pins to bottom synchronously when keyboard appears", async ({
page,
}) => {
await page.goto(`${BASE}/app#/s/${SESSION}`);
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForTimeout(500);
await page.setViewportSize({ width: 380, height: 800 });
await page.evaluate(() => window.__mobuxView.test.injectLines(50, "line"));
await page.waitForTimeout(200);
await page.evaluate(() => window.__mobuxView.swap("reader"));
await page.waitForTimeout(300);
await page.evaluate(() => window.__mobuxView.test.readerStickToBottom());
await page.waitForTimeout(100);
await page.evaluate(() => {
const bar = document.getElementById("inputBar");
bar.classList.remove("hidden");
const vv = window.visualViewport;
Object.defineProperty(vv, "height", {
configurable: true,
get: () =>
typeof window.__stubVVHeight === "number"
? window.__stubVVHeight
: window.innerHeight,
});
Object.defineProperty(vv, "offsetTop", {
configurable: true,
get: () =>
typeof window.__stubVVOffset === "number" ? window.__stubVVOffset : 0,
});
});
const before = await page.evaluate(() => ({
scrollY: window.__mobuxView.test.readerScrollY(),
maxScroll: window.__mobuxView.test.readerMaxScroll(),
readerH: document.getElementById("reader").clientHeight,
}));
expect(before.scrollY).toBe(before.maxScroll);
expect(before.scrollY).toBeGreaterThan(0);
const sync = await page.evaluate(() => {
window.__stubVVHeight = window.innerHeight - 300;
window.visualViewport.dispatchEvent(new Event("resize"));
return {
scrollY: window.__mobuxView.test.readerScrollY(),
maxScroll: window.__mobuxView.test.readerMaxScroll(),
readerH: document.getElementById("reader").clientHeight,
};
});
expect(sync.readerH).toBeLessThan(before.readerH - 250);
expect(sync.maxScroll).toBeGreaterThan(before.maxScroll);
expect(sync.scrollY).toBe(sync.maxScroll);
});
test("theme picker swaps Terminal.colors[2] and #reader --ansi-2 live", async ({
page,
}, testInfo) => {
sterkOnly(test, testInfo);
await page.goto(`${BASE}/app#/s/${SESSION}`);
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForTimeout(800);
const before = await page.evaluate(() => {
const sterk = window.__sterk;
return {
term: sterk?.options?.theme?.palette?.[2] || null,
reader: getComputedStyle(document.getElementById("reader"))
.getPropertyValue("--ansi-2")
.trim(),
};
});
expect(before.term).toBeTruthy();
expect(before.term.toLowerCase()).toBe("#b5bd68");
expect(before.reader.toLowerCase()).toBe("#b5bd68");
const after = await page.evaluate(async () => {
const mod = await import("/static/themes.js");
mod.setStoredThemeId("gruvbox-dark-soft");
mod.applyTheme("gruvbox-dark-soft");
window.dispatchEvent(
new CustomEvent("mobux:theme", { detail: "gruvbox-dark-soft" }),
);
const sterk = window.__sterk;
return {
term: sterk?.options?.theme?.palette?.[2] || null,
reader: getComputedStyle(document.getElementById("reader"))
.getPropertyValue("--ansi-2")
.trim(),
};
});
expect(after.term.toLowerCase()).toBe("#98971a");
expect(after.reader.toLowerCase()).toBe("#98971a");
expect(await page.evaluate(() => window.__mobuxView.test.wsReady())).toBe(
true,
);
await page.evaluate(async () => {
const mod = await import("/static/themes.js");
mod.setStoredThemeId("tomorrow-night-soft");
mod.applyTheme("tomorrow-night-soft");
window.dispatchEvent(
new CustomEvent("mobux:theme", { detail: "tomorrow-night-soft" }),
);
});
});
test("shell integration: status, install, and uninstall round-trip", async ({
page,
}) => {
const fs = require("fs");
const path = require("path");
const rcPath = path.join(SANDBOX_HOME, ".bashrc");
const FENCE_OPEN = "# >>> mobux OSC 133 (managed) >>>";
const FENCE_CLOSE = "# <<< mobux OSC 133 (managed) <<<";
try {
fs.unlinkSync(rcPath);
} catch (_) {}
try {
for (const f of fs.readdirSync(SANDBOX_HOME)) {
if (f.startsWith(".bashrc.mobux.bak.")) {
fs.unlinkSync(path.join(SANDBOX_HOME, f));
}
}
} catch (_) {}
const statusRes = await page.request.get(
`${BASE}/api/shell-integration/status`,
);
expect(statusRes.ok()).toBeTruthy();
const status = await statusRes.json();
for (const sh of ["bash", "zsh", "fish"]) {
expect(status[sh]).toBeTruthy();
expect(typeof status[sh].state).toBe("string");
}
const installRes = await page.request.post(
`${BASE}/api/shell-integration/install`,
{
data: { shell: "bash" },
headers: { "Content-Type": "application/json" },
},
);
expect(installRes.ok()).toBeTruthy();
const afterInstall = await installRes.json();
expect(afterInstall.bash.state).toBe("installed");
expect(typeof afterInstall.bash.version).toBe("number");
expect(afterInstall.bash.version).toBeGreaterThanOrEqual(1);
const rcContent = fs.readFileSync(rcPath, "utf8");
expect(rcContent).toContain(FENCE_OPEN);
expect(rcContent).toContain(FENCE_CLOSE);
expect(rcContent).toContain("PS0=");
expect(rcContent).toContain("\\ePtmux;");
const uninstallRes = await page.request.post(
`${BASE}/api/shell-integration/uninstall`,
{
data: { shell: "bash" },
headers: { "Content-Type": "application/json" },
},
);
expect(uninstallRes.ok()).toBeTruthy();
const afterUninstall = await uninstallRes.json();
expect(["not_installed", "not_present"]).toContain(afterUninstall.bash.state);
if (fs.existsSync(rcPath)) {
const post = fs.readFileSync(rcPath, "utf8");
expect(post).not.toContain(FENCE_OPEN);
expect(post).not.toContain(FENCE_CLOSE);
}
});
test("speaker icons appear on text and prompt bubbles, not code bubbles", async ({
page,
}) => {
await page.goto(`${BASE}/app#/s/${SESSION}`);
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForTimeout(800);
await page.evaluate(() => window.__mobuxView.swap("reader"));
await page.waitForTimeout(150);
await injectRaw(
page,
["~/dev $", "plain text line", "```", "code content", "```"].join("\n") +
"\n",
);
await page.waitForTimeout(250);
const hasSpeech = await page.evaluate(() => "speechSynthesis" in window);
if (!hasSpeech) {
test.skip(true, "speechSynthesis not available");
return;
}
const iconCounts = await page.evaluate(() => {
const prompts = document.querySelectorAll(".rb-prompt .rb-speaker");
const texts = document.querySelectorAll(".rb-text .rb-speaker");
const codes = document.querySelectorAll(".rb-code .rb-speaker");
return {
prompt: prompts.length,
text: texts.length,
code: codes.length,
};
});
expect(iconCounts.prompt).toBeGreaterThan(0);
expect(iconCounts.text).toBeGreaterThan(0);
expect(iconCounts.code).toBe(0);
});
test("clicking speaker icon toggles rb-speaking class", async ({ page }) => {
await page.goto(`${BASE}/app#/s/${SESSION}`);
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForTimeout(800);
await page.evaluate(() => window.__mobuxView.swap("reader"));
await page.waitForTimeout(150);
await injectRaw(page, "test speech line\n");
await page.waitForTimeout(250);
const hasSpeech = await page.evaluate(() => "speechSynthesis" in window);
if (!hasSpeech) {
test.skip(true, "speechSynthesis not available");
return;
}
const iconExists = await page.evaluate(() => {
const icon = document.querySelector(".rb-speaker");
return !!icon;
});
expect(iconExists).toBe(true);
await page.evaluate(() => {
const originalSpeak = window.speechSynthesis.speak;
window.speechSynthesis.speak = (utterance) => {
setTimeout(() => {
if (utterance.onend) utterance.onend();
}, 100);
};
});
await page.evaluate(() => window.__mobuxView.test.readerStickToBottom());
await page.waitForTimeout(100);
await page.evaluate(() => {
const icon = document.querySelector(".rb-speaker");
if (icon) icon.click();
});
await page.waitForTimeout(50);
const hasSpeakingClass = await page.evaluate(() => {
const icon = document.querySelector(".rb-speaker");
return icon && icon.classList.contains("rb-speaking");
});
expect(hasSpeakingClass).toBe(true);
await page.waitForTimeout(100);
const speakingGone = await page.evaluate(() => {
const icon = document.querySelector(".rb-speaker");
return icon && !icon.classList.contains("rb-speaking");
});
expect(speakingGone).toBe(true);
});
test("listen settings visible in settings page when speechSynthesis available", async ({
page,
}) => {
await page.goto(`${BASE}/app#/settings`);
await page.waitForTimeout(300);
const hasSpeech = await page.evaluate(() => "speechSynthesis" in window);
const listenSection = await page.locator("#listen-settings").count();
expect(listenSection).toBe(1);
if (hasSpeech) {
const capableVisible = await page.evaluate(() => {
const el = document.getElementById("listenCapable");
return !!el && !el.hidden;
});
expect(capableVisible).toBe(true);
const unavailableHidden = await page.evaluate(() => {
const el = document.getElementById("listenUnavailable");
return !el || el.hidden; });
expect(unavailableHidden).toBe(true);
await expect(page.locator("#listenVoice")).toBeVisible();
await expect(page.locator("#listenRate")).toBeVisible();
await expect(page.locator("#listenPitch")).toBeVisible();
await expect(page.locator("#listenTest")).toBeVisible();
} else {
const unavailableVisible = await page.evaluate(() => {
const el = document.getElementById("listenUnavailable");
return !!el && !el.hidden;
});
expect(unavailableVisible).toBe(true);
}
});
test("settings page shows current version and update check button", async ({
page,
}) => {
await page.goto(`${BASE}/app#/settings`);
await page.waitForTimeout(300);
await expect(page.locator("#update")).toHaveCount(1);
await expect(page.locator("#updateCheckBtn")).toBeVisible();
await expect
.poll(async () =>
(await page.locator("#updateCurrent").textContent())?.trim(),
)
.toMatch(/^\d+\.\d+\.\d+/);
await page.locator("#updateCheckBtn").click();
await expect
.poll(async () =>
(await page.locator("#updateLatest").textContent())?.trim(),
)
.toBe("999.0.0");
await expect(page.locator("#updateRunBtn")).toBeVisible();
});
test("update run refuses with a structured error (never spawns in tests)", async ({
request,
}) => {
const res = await request.post(`${BASE}/api/update/run`);
expect([409, 412]).toContain(res.status());
const body = await res.json();
expect(body.error).toBeTruthy();
expect(body.error.kind).toMatch(/not_systemd|no_update_available/);
});
test("rb-speaking survives a buffer-change re-render mid-speech", async ({
page,
}) => {
await page.goto(`${BASE}/app#/s/${SESSION}`);
await page.waitForFunction(() => typeof window.__mobuxView !== "undefined", {
timeout: 5000,
});
await page.waitForFunction(
() => window.__mobuxView?.test?.wsReady?.() === true,
{ timeout: 15000 },
);
await page.waitForTimeout(500);
await page.evaluate(() => window.__mobuxView.swap("reader"));
await page.waitForTimeout(150);
const hasSpeech = await page.evaluate(() => "speechSynthesis" in window);
if (!hasSpeech) {
test.skip(true, "speechSynthesis not available");
return;
}
await page.evaluate(() => {
window.speechSynthesis.speak = () => {};
window.speechSynthesis.cancel = () => {};
});
await injectRaw(page, "speakable line for survival test\n");
await page.waitForTimeout(300);
const targetKey = await page.evaluate(() => {
const icons = Array.from(document.querySelectorAll(".rb-text .rb-speaker"));
const icon = icons[icons.length - 1];
if (!icon) return null;
const key = icon.dataset.speechKey;
icon.click();
return key;
});
expect(targetKey).toBeTruthy();
await page.waitForTimeout(50);
const initiallySpeaking = await page.evaluate((key) => {
const icon = document.querySelector(
`.rb-speaker[data-speech-key="${CSS.escape(key)}"]`,
);
return !!(icon && icon.classList.contains("rb-speaking"));
}, targetKey);
expect(initiallySpeaking).toBe(true);
await page.evaluate(() => window.__mobuxView.test.readerForceRender());
const after = await page.evaluate((key) => {
const icon = document.querySelector(
`.rb-speaker[data-speech-key="${CSS.escape(key)}"]`,
);
return {
iconExists: !!icon,
hasClass: !!(icon && icon.classList.contains("rb-speaking")),
speakingCount: document.querySelectorAll(".rb-speaker.rb-speaking")
.length,
};
}, targetKey);
expect(after.iconExists).toBe(true);
expect(after.hasClass).toBe(true);
expect(after.speakingCount).toBe(1);
});
test("host picker: trigger renders, current node listed first", async ({
page,
}) => {
await page.goto(`${BASE}/app#/`);
await page.waitForFunction(() => typeof window.MobuxMesh !== "undefined", {
timeout: 8000,
});
await expect(page.locator(".spa-host-picker .host-select")).toBeVisible();
const peer = await page.evaluate(() => window.MobuxMesh.getPeer());
expect(peer).toBe("");
const firstOptText = await page
.locator(".host-select option")
.first()
.textContent();
expect(firstOptText).toContain("This host");
const selectedVal = await page.$eval(".host-select", (el) => el.value);
expect(selectedVal).toBe("");
});
test("host picker: never shows an empty picker (error or hint)", async ({
page,
}) => {
await page.goto(`${BASE}/app#/`);
await page.waitForFunction(() => typeof window.MobuxMesh !== "undefined", {
timeout: 8000,
});
await expect(page.locator(".host-select")).toBeVisible();
const optCount = await page.locator(".host-select option").count();
expect(optCount).toBeGreaterThan(0);
const firstOpt = await page
.locator(".host-select option")
.first()
.textContent();
expect(firstOpt.trim().length).toBeGreaterThan(0);
});
test("host picker: selecting current node changes nothing (same-origin)", async ({
page,
}) => {
await page.goto(`${BASE}/app#/`);
await page.waitForFunction(() => typeof window.MobuxMesh !== "undefined", {
timeout: 8000,
});
await page.selectOption(".host-select", { value: "" });
const state = await page.evaluate(() => ({
peer: window.MobuxMesh.getPeer(),
apiPath: window.MobuxMesh.apiPath("/api/sessions"),
ws: window.MobuxMesh.wsUrl("demo"),
}));
expect(state.peer).toBe("");
expect(state.apiPath).toBe("/api/sessions");
expect(state.ws).not.toContain("/r/");
expect(state.ws).not.toContain("upstream_auth");
const res = await page.request.get(`${BASE}/api/sessions`);
expect(res.ok()).toBeTruthy();
});
test("mesh client: peer selection rewrites API + WS paths and carries creds", async ({
page,
}) => {
await page.goto(`${BASE}/app#/`);
await page.waitForFunction(() => typeof window.MobuxMesh !== "undefined", {
timeout: 8000,
});
const out = await page.evaluate(() => {
const m = window.MobuxMesh;
m.setPeer("peerhost:5151");
m.setPeerCred("peerhost:5151", "bob", "12345");
const result = {
apiPath: m.apiPath("/api/sessions"),
ws: m.wsUrl("demo"),
cred: m.getPeerCred("peerhost:5151"),
};
m.setPeer("");
m.clearPeerCred("peerhost:5151");
return result;
});
expect(out.apiPath).toBe("/r/peerhost%3A5151/api/sessions");
expect(out.ws).toContain("/r/peerhost%3A5151/ws/demo");
expect(out.ws).toContain("upstream_auth=");
expect(out.cred).toBe(btoa("bob:12345"));
});
test("session list: relayed error body renders as text, not HTML (XSS)", async ({
page,
}) => {
await page.goto(`${BASE}/app#/`);
await page.waitForFunction(
() => typeof window.refreshSessions === "function",
);
await page.waitForFunction(() => typeof window.MobuxMesh !== "undefined", {
timeout: 8000,
});
await page.evaluate(() => {
window.MobuxMesh.apiFetchJSON = async () => {
throw new Error("<img src=x onerror=window.__xss=1>");
};
return window.refreshSessions();
});
const list = page.locator("#sessionList");
await expect(list.locator(".hint")).toContainText("<img src=x onerror=");
expect(await list.locator("img").count()).toBe(0);
expect(await page.evaluate(() => window.__xss)).toBeUndefined();
});
test("session list: peer-controlled session names are escaped", async ({
page,
}) => {
await page.goto(`${BASE}/app#/`);
await page.waitForFunction(
() => typeof window.refreshSessions === "function",
);
await page.waitForFunction(() => typeof window.MobuxMesh !== "undefined", {
timeout: 8000,
});
await page.evaluate(() => {
window.MobuxMesh.apiFetchJSON = async () => [
{ name: "<b>pwn</b>", windows: 1, attached: 0 },
];
return window.refreshSessions();
});
const list = page.locator("#sessionList");
await expect(list.locator(".session-name")).toHaveText("<b>pwn</b>");
expect(await list.locator(".session-name b").count()).toBe(0);
});
test("mesh client: manual peer add / normalize / remove APIs work", async ({
page,
}) => {
await page.goto(`${BASE}/app#/`);
await page.waitForFunction(() => typeof window.MobuxMesh !== "undefined", {
timeout: 8000,
});
const added = await page.evaluate(() => {
const m = window.MobuxMesh;
m.removeManualPeer("manualbox:7000"); return { id: m.addManualPeer("manualbox:7000"), list: m.getManualPeers() };
});
expect(added.id).toBe("manualbox:7000");
expect(added.list).toContain("manualbox:7000");
const norm = await page.evaluate(() =>
window.MobuxMesh.normalizeManualPeer("barehost"),
);
expect(norm).toMatch(/^barehost:\d+$/);
const after = await page.evaluate(() => {
window.MobuxMesh.removeManualPeer("manualbox:7000");
return window.MobuxMesh.getManualPeers();
});
expect(after).not.toContain("manualbox:7000");
});
test("GET /settings 307-redirects to /app#/settings", async ({ page }) => {
const resp = await page.request.get(`${BASE}/settings`, {
maxRedirects: 0,
});
expect(resp.status()).toBe(307);
expect(resp.headers()["location"]).toBe("/app#/settings");
});
test("GET /s/<name> 307-redirects to /app#/s/<name>", async ({ page }) => {
const resp = await page.request.get(`${BASE}/s/${SESSION}`, {
maxRedirects: 0,
});
expect(resp.status()).toBe(307);
expect(resp.headers()["location"]).toBe(`/app#/s/${SESSION}`);
});
test("GET /install 307-redirects to /app#/install", async ({ page }) => {
const resp = await page.request.get(`${BASE}/install`, {
maxRedirects: 0,
});
expect(resp.status()).toBe(307);
expect(resp.headers()["location"]).toBe("/app#/install");
});
test("GET /s/<host>/<name> 307-redirects to /app#/s/<encoded-host>/<name>", async ({
page,
}) => {
const resp = await page.request.get(`${BASE}/s/box:8443/${SESSION}`, {
maxRedirects: 0,
});
expect(resp.status()).toBe(307);
expect(resp.headers()["location"]).toBe(`/app#/s/box%3A8443/${SESSION}`);
});
test.fixme("host picker: manual add host appears in picker with label and remove button", async ({
page,
}) => {
await page.goto(`${BASE}/app#/`);
await page.waitForFunction(() => typeof window.MobuxMesh !== "undefined", {
timeout: 8000,
});
await page.evaluate(() => {
const m = window.MobuxMesh;
m.removeManualPeer("manualbox:7000");
m.addManualPeer("manualbox:7000");
});
await page.locator(".host-trigger").click();
await expect(
page.locator(".peer-list .hint", { hasText: "Loading" }),
).toHaveCount(0);
const manualOpt = page.locator(".peer-option", { hasText: "manualbox:7000" });
await expect(manualOpt).toBeVisible();
await expect(manualOpt.locator(".peer-sub")).toHaveText("manual");
await expect(manualOpt.locator(".peer-remove")).toBeVisible();
});