const { test, expect } = require("./fixtures.cjs");
const { execSync } = require("child_process");
const BASE = process.env.MOBUX_URL || "https://localhost:5151";
const APP = `${BASE}/app`;
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 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" });
const SEED = `spa-seed-${process.pid}`;
test.use({
...(AUTH ? { extraHTTPHeaders: { Authorization: AUTH } } : {}),
});
test.beforeAll(() => {
try {
tmux(`kill-session -t ${SEED}`);
} catch (_) {}
tmux(`new-session -d -s ${SEED} ${SHELL_ENV} "bash --norc --noprofile"`);
tmux(`send-keys -t ${SEED} "PS1='\\$ '" Enter`);
tmux(`send-keys -t ${SEED} "clear" Enter`);
execSync("sleep 0.3");
});
test.afterAll(() => {
try {
tmux(`kill-session -t ${SEED}`);
} catch (_) {}
});
test("app route serves the SPA shell and Home lists sessions", async ({
page,
}) => {
await page.goto(`${APP}#/`, { waitUntil: "networkidle" });
await expect(page.locator("#app")).toHaveCount(1);
await expect(page.locator(".app-wordmark")).toBeVisible();
await expect(page.locator("select.host-select")).toBeVisible();
await expect(
page.locator('button.header-icon-btn[aria-label="Settings"]'),
).toBeVisible();
await expect(page.locator(".spa-nav")).toHaveCount(0);
await expect(page.locator(".spa-nav a", { hasText: "Home" })).toHaveCount(0);
await expect(page.locator(".spa-nav a", { hasText: "Install" })).toHaveCount(
0,
);
await expect(page.locator("#sessionList .session-item").first()).toBeVisible({
timeout: 8000,
});
const names = await page
.locator("#sessionList .session-name")
.allTextContents();
expect(names.some((n) => n.trim() === SEED)).toBeTruthy();
await expect(page.locator("#fabNew")).toBeVisible();
});
async function swipeReveal(page, rowName, dir) {
await page.evaluate(
({ rowName, dir }) => {
const row = document.querySelector(
`#sessionList .swipe-row[data-name="${rowName}"]`,
);
const item = row.querySelector(".session-item");
const rect = item.getBoundingClientRect();
const y = rect.top + rect.height / 2;
const x0 = rect.left + rect.width / 2;
const mkTouch = (clientX) =>
new Touch({ identifier: 0, target: item, clientX, clientY: y });
const fire = (type, touches) =>
item.dispatchEvent(
new TouchEvent(type, { bubbles: true, cancelable: true, touches }),
);
fire("touchstart", [mkTouch(x0)]);
fire("touchmove", [mkTouch(x0 + dir * 90)]);
fire("touchend", []);
},
{ rowName, dir },
);
}
test("session lifecycle: create, rename, and kill through the SPA", async ({
page,
}) => {
const name = `spa-life-${process.pid}-${Date.now() % 100000}`;
const renamed = `${name}-r`;
await page.goto(`${APP}#/`, { waitUntil: "networkidle" });
await page.locator("#fabNew").click();
await expect(page.locator("#newSessionDialog")).toBeVisible();
await page.locator("#sessionName").fill(name);
await page.locator("#newSessionForm .btn-create").click();
const row = page.locator(`#sessionList .swipe-row[data-name="${name}"]`);
await expect(row).toBeVisible({ timeout: 8000 });
let api = await page.evaluate(async () =>
(await fetch("/api/sessions")).json(),
);
let list = (Array.isArray(api) ? api : api.sessions || []).map((s) =>
typeof s === "string" ? s : s.name,
);
expect(list).toContain(name);
page.once("dialog", (d) => d.accept(renamed));
await swipeReveal(page, name, 1);
await row.locator(".rename-btn").click();
await expect(
page.locator(`#sessionList .swipe-row[data-name="${renamed}"]`),
).toBeVisible({ timeout: 8000 });
api = await page.evaluate(async () => (await fetch("/api/sessions")).json());
list = (Array.isArray(api) ? api : api.sessions || []).map((s) =>
typeof s === "string" ? s : s.name,
);
expect(list).toContain(renamed);
expect(list).not.toContain(name);
page.once("dialog", (d) => d.accept());
await swipeReveal(page, renamed, -1);
await page
.locator(`#sessionList .swipe-row[data-name="${renamed}"] .kill-btn`)
.click();
await expect(
page.locator(`#sessionList .swipe-row[data-name="${renamed}"]`),
).toHaveCount(0, { timeout: 8000 });
api = await page.evaluate(async () => (await fetch("/api/sessions")).json());
list = (Array.isArray(api) ? api : api.sessions || []).map((s) =>
typeof s === "string" ? s : s.name,
);
expect(list).not.toContain(renamed);
});
test("terminal island mounts and the PTY websocket connects", async ({
page,
}) => {
const wsConnected = new Promise((resolve) => {
page.on("websocket", (ws) => {
if (ws.url().includes(`/ws/${encodeURIComponent(SEED)}`))
resolve(ws.url());
});
});
await page.goto(`${APP}#/s/${encodeURIComponent(SEED)}`, {
waitUntil: "networkidle",
});
await expect(page.locator("#terminal")).toHaveCount(1);
const wsUrl = await Promise.race([
wsConnected,
new Promise((_, rej) =>
setTimeout(() => rej(new Error("ws timeout")), 15000),
),
]);
expect(wsUrl).toContain(`/ws/${encodeURIComponent(SEED)}`);
await page.waitForFunction(
() => {
const t = document.getElementById("terminal");
return t && t.childElementCount > 0;
},
{ timeout: 15000 },
);
});
test("terminal island fills the viewport on mount (correct PTY rows)", async ({
page,
}) => {
await page.goto(`${APP}#/s/${encodeURIComponent(SEED)}`, {
waitUntil: "networkidle",
});
await page.waitForFunction(
() => {
const t = document.getElementById("terminal");
return t && t.childElementCount > 0;
},
{ timeout: 15000 },
);
await page.waitForTimeout(500);
const geo = await page.evaluate(() => {
const t = document.getElementById("terminal");
const r = t.getBoundingClientRect();
return {
hostTop: r.top,
hostBottom: r.bottom,
hostHeight: r.height,
viewportHeight: window.innerHeight,
rows: window.__mobuxView?.test?.rows?.() ?? null,
};
});
expect(geo.hostTop).toBeLessThan(8);
expect(geo.hostHeight).toBeGreaterThan(geo.viewportHeight * 0.85);
expect(Math.abs(geo.viewportHeight - geo.hostBottom)).toBeLessThan(8);
const minRows = Math.floor((geo.hostHeight / geo.viewportHeight) * 30);
expect(geo.rows).toBeGreaterThanOrEqual(Math.max(20, minRows));
});
test("control-key ribbon is horizontally scrollable (not wrapped/clipped)", async ({
page,
}) => {
await page.goto(`${APP}#/s/${encodeURIComponent(SEED)}`, {
waitUntil: "networkidle",
});
await expect(page.locator("#inputRibbon")).toHaveCount(1);
await page.evaluate(() => {
const bar = document.getElementById("inputBar");
if (bar) bar.classList.remove("hidden");
});
await page.waitForTimeout(100);
const ribbon = page.locator("#inputRibbon");
const m = await ribbon.evaluate((el) => {
const cs = getComputedStyle(el);
const btns = [...el.querySelectorAll("button")];
const tops = new Set(btns.map((b) => b.offsetTop));
return {
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
overflowX: cs.overflowX,
flexWrap: cs.flexWrap,
rowCount: tops.size,
buttonCount: btns.length,
};
});
expect(m.buttonCount).toBeGreaterThan(5);
expect(m.scrollWidth).toBeGreaterThan(m.clientWidth);
expect(["auto", "scroll"]).toContain(m.overflowX);
expect(m.flexWrap).toBe("nowrap");
expect(m.rowCount).toBe(1);
const moved = await ribbon.evaluate((el) => {
el.scrollLeft = 0;
el.scrollLeft = 80;
return el.scrollLeft;
});
expect(moved).toBeGreaterThan(0);
});
test("settings: every ported card renders and consumes its endpoint", async ({
page,
}) => {
const seen = new Set();
page.on("request", (r) => {
const u = new URL(r.url()).pathname;
if (u.startsWith("/api/") || u.startsWith("/static/"))
seen.add(`${r.method()} ${u}`);
});
await page.goto(`${APP}#/settings`, { waitUntil: "networkidle" });
await expect(page.locator("#update h2")).toHaveText("Software update");
await expect(page.locator("#renderer-picker")).toBeVisible();
await expect(page.locator("#theme-picker")).toBeVisible();
await expect(page.locator("#shell-integration")).toBeVisible();
await expect(page.locator("#stt-provider")).toBeVisible();
await expect(page.locator("section#install-app")).toBeVisible();
await expect(page.locator('input[name="bell"]')).toHaveCount(1);
await expect(page.locator('input[name="program_exit_nonzero"]')).toHaveCount(
1,
);
await page.waitForFunction(
() => document.querySelectorAll("#theme-picker option").length > 0,
{ timeout: 6000 },
);
await expect(
page.locator(
'#shell-integration .shell-card[data-shell="bash"] [data-role="state"]',
),
).not.toHaveText("…", { timeout: 6000 });
await expect(page.locator("#update .settings-value").first()).not.toHaveText(
"…",
{ timeout: 8000 },
);
await expect(page.locator("#listen-settings h2")).toHaveText("Listen");
await expect(page.locator("#build-info h2")).toHaveText("Build");
for (const want of [
"GET /api/update/status",
"GET /api/settings/notifications",
"GET /api/shell-integration/status",
"GET /api/settings/stt",
"GET /api/build-info",
"GET /static/build-info.json",
]) {
expect(seen.has(want), `expected ${want}`).toBeTruthy();
}
});
test("settings: STT provider switch shows the right fields and auto-saves", async ({
page,
}) => {
await page.goto(`${APP}#/settings`, { waitUntil: "networkidle" });
await page.waitForSelector("#stt-provider");
const kind = page.locator("#sttKind");
await kind.selectOption("network");
await expect(page.locator("#sttHost")).toBeVisible();
await expect(page.locator("#sttPort")).toBeVisible();
await expect(page.locator("#sttModelRow")).toBeVisible();
await expect(page.locator("#sttApiKey")).toHaveCount(0);
await expect(page.locator("#sttInstallBtn")).toHaveCount(0);
await kind.selectOption("openai");
await expect(page.locator("#sttApiKey")).toBeVisible();
await expect(page.locator("#sttModelRow")).toBeVisible();
await expect(page.locator("#sttHost")).toHaveCount(0);
await expect(page.locator("#sttPort")).toHaveCount(0);
await kind.selectOption("local");
await expect(page.locator("#sttInstallBtn")).toBeVisible();
await expect(page.locator("#sttToggleBtn")).toBeVisible();
await expect(page.locator("#sttHost")).toHaveCount(0);
await kind.selectOption("network");
const probe = String(5290 + Math.floor(Math.random() * 9));
const portEl = page.locator("#sttPort");
await portEl.fill(probe);
await portEl.blur();
await expect(page.locator("#sttStatus")).toContainText("Saved", {
timeout: 6000,
});
const cfg = await page.evaluate(async () =>
(await fetch("/api/settings/stt")).json(),
);
expect(cfg.activeKind).toBe("network");
expect(cfg.providers.network.port).toBe(probe);
});
test("settings: build-info card shows version and matching hashes", async ({
page,
}) => {
await page.goto(`${APP}#/settings`, { waitUntil: "networkidle" });
await expect(page.locator("#build-info h2")).toHaveText("Build");
await expect(page.locator("#buildVersion")).not.toHaveText("…", {
timeout: 6000,
});
await expect(page.locator("#buildServerHash")).not.toHaveText("…", {
timeout: 6000,
});
await expect(page.locator("#buildFeHash")).not.toHaveText("—", {
timeout: 6000,
});
const srv = await page.locator("#buildServerHash").textContent();
const fe = await page.locator("#buildFeHash").textContent();
expect(srv.trim()).toBe(fe.trim());
});
test("mesh-client loads once: no double-declaration error navigating home then terminal", async ({
page,
}) => {
const pageErrors = [];
page.on("pageerror", (err) => pageErrors.push(err.message));
await page.goto(`${APP}#/`, { waitUntil: "networkidle" });
await page.waitForFunction(() => !!window.MobuxMesh, { timeout: 10000 });
await page.goto(`${APP}#/s/${encodeURIComponent(SEED)}`, {
waitUntil: "networkidle",
});
await expect(page.locator("#terminal")).toHaveCount(1);
await page.waitForFunction(
() => {
const t = document.getElementById("terminal");
return t && t.childElementCount > 0;
},
{ timeout: 15000 },
);
const termHeight = await page.evaluate(() => {
const t = document.getElementById("terminal");
return t ? t.getBoundingClientRect().height : 0;
});
expect(termHeight).toBeGreaterThan(0);
expect(await page.evaluate(() => !!window.MobuxMesh)).toBe(true);
const doubleDecl = pageErrors.filter((m) =>
m.includes("already been declared"),
);
expect(
doubleDecl,
`double-declaration errors: ${doubleDecl.join("; ")}`,
).toHaveLength(0);
const syntaxErrors = pageErrors.filter((m) =>
m.toLowerCase().includes("syntaxerror"),
);
expect(
syntaxErrors,
`unexpected SyntaxErrors: ${syntaxErrors.join("; ")}`,
).toHaveLength(0);
});
test("second terminal open renders without engine boot error", async ({
page,
}) => {
const pageErrors = [];
page.on("pageerror", (err) => pageErrors.push(err.message));
const SEED2 = `spa-seed2-${process.pid}`;
try {
tmux(`kill-session -t ${SEED2}`);
} catch (_) {}
tmux(`new-session -d -s ${SEED2} ${SHELL_ENV} "bash --norc --noprofile"`);
try {
await page.goto(`${APP}#/`, { waitUntil: "networkidle" });
await expect(
page.locator(
`#sessionList .swipe-row[data-name="${SEED}"] .session-item`,
),
).toBeVisible({ timeout: 8000 });
await expect(
page.locator(
`#sessionList .swipe-row[data-name="${SEED2}"] .session-item`,
),
).toBeVisible({ timeout: 8000 });
await Promise.all([
page.waitForNavigation({ waitUntil: "networkidle" }),
page
.locator(`#sessionList .swipe-row[data-name="${SEED}"] .session-item`)
.click(),
]);
await page.waitForFunction(
() => {
const t = document.getElementById("terminal");
return t && t.childElementCount > 0;
},
{ timeout: 15000 },
);
expect(
await page.evaluate(
() =>
document.getElementById("terminal").getBoundingClientRect().height,
),
).toBeGreaterThan(0);
await page.goto(`${APP}#/`, { waitUntil: "networkidle" });
await expect(
page.locator(
`#sessionList .swipe-row[data-name="${SEED2}"] .session-item`,
),
).toBeVisible({ timeout: 8000 });
await Promise.all([
page.waitForNavigation({ waitUntil: "networkidle" }),
page
.locator(`#sessionList .swipe-row[data-name="${SEED2}"] .session-item`)
.click(),
]);
await page.waitForFunction(
() => {
const t = document.getElementById("terminal");
return t && t.childElementCount > 0;
},
{ timeout: 15000 },
);
expect(
await page.evaluate(
() =>
document.getElementById("terminal").getBoundingClientRect().height,
),
).toBeGreaterThan(0);
expect(await page.evaluate(() => !!window.MobuxMesh)).toBe(true);
const doubleDecl = pageErrors.filter((m) =>
m.includes("already been declared"),
);
expect(
doubleDecl,
`double-declaration errors: ${doubleDecl.join("; ")}`,
).toHaveLength(0);
} finally {
try {
tmux(`kill-session -t ${SEED2}`);
} catch (_) {}
}
});
test("install page renders QR codes for CA and APK", async ({ page }) => {
await page.goto(`${APP}#/install`, { waitUntil: "networkidle" });
const qrs = page.locator(".install-qr");
await expect(qrs).toHaveCount(2);
await expect(qrs.first().locator("svg")).toBeVisible();
await expect(qrs.nth(1).locator("svg")).toBeVisible();
await expect(page.locator('a[href="/install/mobux-ca.crt"]')).toBeVisible();
await expect(page.locator('a[href="/install/mobux.apk"]')).toBeVisible();
});
test('mesh host picker is a native select with "This host" as default', async ({
page,
}) => {
await page.goto(`${APP}#/`, { waitUntil: "networkidle" });
const picker = page.locator(".spa-host-picker");
await expect(picker).toBeVisible();
const select = picker.locator("select.host-select");
await expect(select).toBeVisible();
await expect(select).toHaveValue("");
const thisHostOption = select.locator('option[value=""]');
await expect(thisHostOption).toHaveText("This host");
await expect(page.locator(".spa-host-dropdown")).toHaveCount(0);
await expect(page.locator(".host-trigger")).toHaveCount(0);
});