// Real renderer and routes in a disposable fake-engine workspace only. import { spawn, type ChildProcess } from "node:child_process"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterAll, expect, it } from "vitest"; import { resolveAgentBrowserBinary } from "../../server/browser-engine.ts"; import { waitForExit } from "../../server/testing/cleanup.ts"; import type { Routine } from "../../src/lib/routines.ts"; import { runControlOmb } from "../control-omb.ts"; import { UI_TOOLS_DIR } from "./control-omb-ui.ts"; const ROOT = fileURLToPath(new URL("../..", import.meta.url)); const enabled = process.env.OMB_UI_E2E === "1" || Boolean(resolveAgentBrowserBinary({ dataDir: UI_TOOLS_DIR, env: process.env })); let child: ChildProcess | undefined; afterAll(() => waitForExit(child, { signal: "SIGINT", graceMs: 30_000 })); (enabled ? it : it.skip)("creates monthly routines, validates cron, preserves arbitrary expressions and excludes calls", async () => { let output = ""; let stderr = ""; child = spawn(process.execPath, ["--experimental-strip-types", join(ROOT, "scripts/control-omb.ts"), "ui", "launch"], { cwd: ROOT, env: process.env, stdio: ["ignore", "pipe", "pipe"] }); child.stdout!.on("data", chunk => { output += String(chunk); }); child.stderr!.on("data", chunk => { stderr += String(chunk); }); let fixture: { ui: string; url: string; botId: string; logPath: string }; await expect.poll(() => { if (child!.exitCode !== null) throw new Error(stderr); try { fixture = JSON.parse(output); return Boolean(fixture.ui); } catch { return false; } }, { timeout: 600_000 }).toBe(true); const ui = (verb: string, ...args: string[]) => runControlOmb(["ui", verb, "--ui", fixture.ui, ...args]) as Promise>; const evaluate = async (js: string) => (await ui("eval", "--js", js)).result; const click = async (name: string) => { await evaluate(`[...document.querySelectorAll('button')].find(button => button.textContent === ${JSON.stringify(name)})?.scrollIntoView({ block: 'center' })`); return ui("click", "--name", name); }; // The shared harness lacks select/fill verbs. Drive native controls through // DOM input/change events; all saves and reads still use the real renderer. const fill = (selector: string, value: string) => evaluate(`(() => { const field = document.querySelector(${JSON.stringify(selector)}); const prototype = field instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; Object.getOwnPropertyDescriptor(prototype, 'value').set.call(field, ${JSON.stringify(value)}); field.dispatchEvent(new Event('input', { bubbles: true })); return field.value; })()`); const select = (label: string, value: string) => evaluate(`(() => { const field = document.querySelector('select[aria-label=${JSON.stringify(label)}]') ?? [...document.querySelectorAll('label')].find(label => label.firstElementChild?.textContent === ${JSON.stringify(label)})?.control; field.value = ${JSON.stringify(value)}; field.dispatchEvent(new Event('change', { bubbles: true })); return field.value; })()`); const routines = async (): Promise => (await fetch(`${fixture.url}/api/routines`).then(response => response.json())).routines; const repeatOptions = () => evaluate("[...document.querySelector('select[aria-label=Repeat]').options].map(option => option.value)"); const openOverlap = () => evaluate(`(() => { const field = document.querySelector('select[aria-label="If the previous run is still working"]'); const details = field.closest('details'); if (!details.open) details.querySelector('summary').click(); field.scrollIntoView({ block: 'center' }); return field.getBoundingClientRect().height > 0; })()`); const startupBox = `document.querySelector('input[aria-label="Also run when MausBot starts"]')`; const title = 'input[placeholder="What should happen?"]'; const newButton = "[...document.querySelectorAll('main header button')].find(button => button.textContent === 'New')"; await click("Automations"); await expect.poll(() => evaluate(`Boolean(${newButton})`)).toBe(true); await evaluate(`${newButton}.click()`); await click("Set it up yourself"); await click("On a schedule"); await click("Continue"); await fill(title, "Monthly close"); await fill('textarea[placeholder="Tell the bot what to do each time"]', "Summarize this fixture only."); await click("More options"); expect(await evaluate(`document.querySelector('${title}').value`)).toBe("Monthly close"); await click("Call"); const callOptions = await repeatOptions(); for (const choice of ["monthly", "yearly", "cron"]) expect(callOptions).not.toContain(choice); await click("Routine"); expect(await evaluate("document.body.innerText")).toContain("Bot’s current setup"); expect(await evaluate("document.body.innerText")).toContain("Runs the whole job on the bot's cloud desktop"); expect(await evaluate("document.body.innerText")).toContain("including a self-hosted VPS"); expect(await repeatOptions()).toEqual(expect.arrayContaining(["monthly", "yearly", "cron"])); // A one-time routine has no schedule to run beside, so it isn't offered. expect(await evaluate(`${startupBox} === null`)).toBe(true); await select("Repeat", "monthly"); expect(await openOverlap()).toBe(true); expect(await evaluate(`${startupBox}.checked`)).toBe(false); expect(await evaluate(`(() => { ${startupBox}.click(); return ${startupBox}.checked; })()`)).toBe(true); expect(await evaluate('document.querySelector(\'select[aria-label="If the previous run is still working"]\').value')).toBe("skip"); await select("If the previous run is still working", "queue"); await select("Day of month", "L"); await fill('input[type="time"]', "09:00"); await fill('input[list="routine-time-zones"]', "UTC"); await expect.poll(() => evaluate('document.querySelectorAll(\'[aria-label="Next scheduled runs"] time\').length')).toBe(3); await evaluate('document.querySelector(\'[aria-label="Next scheduled runs"]\').scrollIntoView({ block: "center" })'); await ui("screenshot", "--out", `${fixture!.logPath}.cron-monthly.png`); expect(await openOverlap()).toBe(true); await ui("screenshot", "--out", `${fixture!.logPath}.routine-overlap.png`); await select("Repeat", "cron"); expect(await evaluate('document.querySelector(\'input[placeholder="0 9 1 * *"]\').value')).toBe("0 9 L * *"); await fill('input[placeholder="0 9 1 * *"]', "0 9 31 2 *"); await expect.poll(() => evaluate('document.querySelector("[role=alert]")?.textContent')).toContain("no future runs"); expect(await evaluate("[...document.querySelectorAll('[role=dialog] button')].find(button => button.textContent === 'Save').disabled")).toBe(true); await select("Repeat", "monthly"); await click("Save"); await expect.poll(routines).toHaveLength(1); const monthly = (await routines())[0]; expect(monthly).toMatchObject({ name: "Monthly close", overlap: "queue", runOnStartup: true, prompt: "Summarize this fixture only.", schedule: { type: "cron", expression: "0 9 L * *", timeZone: "UTC" } }); await click("Monthly close"); await click("Edit"); expect(await evaluate("document.querySelector('select[aria-label=Repeat]').value")).toBe("monthly"); expect(await evaluate('document.querySelector(\'select[aria-label="If the previous run is still working"]\').value')).toBe("queue"); expect(await openOverlap()).toBe(true); expect(await evaluate(`${startupBox}.checked`)).toBe(true); await select("If the previous run is still working", "skip"); await select("Repeat", "yearly"); await select("Month", "2"); await select("Day of month", "29"); await click("Save"); await expect.poll(async () => (await routines()).find(routine => routine.id === monthly.id)?.schedule).toEqual({ type: "cron", expression: "0 9 29 2 *", timeZone: "UTC" }); expect((await routines()).find(routine => routine.id === monthly.id)?.overlap).toBeUndefined(); expect((await routines()).find(routine => routine.id === monthly.id)?.runOnStartup).toBe(true); // The API models a bot-created arbitrary schedule. Editing only its title // must not collapse its ranges, weekdays, or non-local timezone to a preset. const customSchedule = { type: "cron", expression: "15 9-17/2 * * 1-5", timeZone: "America/New_York" }; const response = await fetch(`${fixture.url}/api/routines`, { method: "POST", headers: { "content-type": "application/json", origin: fixture.url }, body: JSON.stringify({ name: "Business-hours report", prompt: "Use only the fixture.", botId: monthly.botId, enabled: false, runOn: "maus", schedule: customSchedule }) }); expect(response.status).toBe(201); const custom = (await response.json()).routine; await expect.poll(() => evaluate(`document.querySelector('li[data-automation="${custom.id}"]')?.dataset.automationStatus`)).toBe("paused"); await click("Business-hours report"); await click("Edit"); expect(await evaluate("document.querySelector('select[aria-label=Repeat]').value")).toBe("cron"); expect(await evaluate('document.querySelector(\'input[list="routine-time-zones"]\').value')).toBe("America/New_York"); await fill(title, "Renamed report"); await click("Save"); await expect.poll(async () => (await routines()).find(routine => routine.id === custom.id)?.name).toBe("Renamed report"); expect((await routines()).find(routine => routine.id === custom.id)?.schedule).toEqual(customSchedule); const browserConsole = await ui("console"); expect((browserConsole.messages as Array<{ type: string }>).filter(message => message.type === "error")).toEqual([]); console.log(`Cron UI evidence: ${fixture!.logPath}.cron-monthly.png`); }, 720_000);