BangSo/apps/web/e2e/routine-crud.spec.ts

320 lines
12 KiB
TypeScript
Raw Permalink Normal View History

2026-09-01 16:51:19 +00:00
import { expect, type Page, test } from "@playwright/test";
import type { Bot, Routine } from "@rakazo/contracts";
import { activeBotId, captureScreenshot, completeOnboarding, rpc, signup } from "./helpers";
async function addScheduleTrigger(page: Page, freq: string) {
await page.getByRole("button", { name: "Add trigger" }).click();
await page.getByRole("menuitem", { name: "On a schedule" }).hover();
await page.getByRole("menuitem", { name: freq, exact: true }).click();
}
async function saveAndReturn(page: Page, procedure: "routines/create" | "routines/update") {
const saved = page.waitForResponse(
(response) => response.url().includes(`/rpc/${procedure}`) && response.ok(),
);
await page.getByRole("button", { name: "Save", exact: true }).click();
await saved;
await expect(page.getByRole("button", { name: "Save", exact: true })).toBeEnabled();
await page.getByRole("button", { name: "Back" }).click();
}
test("routine active switch keeps its thumb inside the track", async ({ page }, testInfo) => {
const stamp = Date.now();
await signup(page, `routine-toggle-${stamp}@rakazo.test`, "password12", "Routine Toggle");
await completeOnboarding(page);
await page.getByTitle("Agent computer").click();
await page.getByRole("button", { name: "New routine" }).click();
const toggle = page.getByRole("switch", { name: "Active" });
const thumb = toggle.locator("span");
async function expectThumbInsets(left: number, right: number) {
await thumb.evaluate((element) =>
Promise.all(element.getAnimations().map(({ finished }) => finished)),
);
const [trackBox, thumbBox] = await Promise.all([toggle.boundingBox(), thumb.boundingBox()]);
expect(trackBox).not.toBeNull();
expect(thumbBox).not.toBeNull();
expect(thumbBox!.x - trackBox!.x).toBeCloseTo(left, 1);
expect(trackBox!.x + trackBox!.width - thumbBox!.x - thumbBox!.width).toBeCloseTo(right, 1);
}
await expect(toggle).toHaveAttribute("aria-checked", "true");
await expectThumbInsets(20, 2);
await captureScreenshot(page, testInfo, "routine-toggle-desktop-active");
await toggle.click();
await expect(toggle).toHaveAttribute("aria-checked", "false");
await expectThumbInsets(2, 20);
await captureScreenshot(page, testInfo, "routine-toggle-desktop-inactive");
await toggle.focus();
await page.keyboard.press("Space");
await expect(toggle).toHaveAttribute("aria-checked", "true");
expect(await toggle.evaluate((element) => getComputedStyle(element).outlineStyle)).not.toBe(
"none",
);
await page.setViewportSize({ width: 390, height: 844 });
await expectThumbInsets(20, 2);
await captureScreenshot(page, testInfo, "routine-toggle-mobile-active");
await toggle.click();
await expectThumbInsets(2, 20);
await captureScreenshot(page, testInfo, "routine-toggle-mobile-inactive");
});
test("routine editing updates in place, preserves timezone, and deletion persists", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `routine-crud-${stamp}@rakazo.test`, "password12", "Routine CRUD");
await completeOnboarding(page);
const botId = activeBotId(page);
const created = await rpc<Routine>(page, "routines/create", {
botId,
name: "Tokyo check-in",
prompt: "Send the original update",
crons: ["0 9 * * *"],
timezone: "Asia/Tokyo",
active: true,
notify: true,
});
expect(created.nextRunAt).not.toBeNull();
expect(localSchedule(created.nextRunAt!, created.timezone)).toMatchObject({ hour: 9, minute: 0 });
await page.reload();
await page.getByTitle("Agent computer").click();
await page.getByRole("button", { name: /Tokyo check-in/ }).click();
await page.locator("label:has-text('Name') input").fill("Weekday check-in");
await page.locator("label:has-text('Instruction') textarea").fill("Send the revised update");
await page.getByLabel("How often").selectOption("Weekdays");
await saveAndReturn(page, "routines/update");
const updatedButton = page.getByRole("button", { name: /Weekday check-in/ });
await expect(updatedButton).toHaveCount(1);
await expect(updatedButton).toContainText("Weekdays at 9:00 AM");
await expect(page.getByRole("button", { name: /Tokyo check-in/ })).toHaveCount(0);
const [updated] = await rpc<Routine[]>(page, "routines/list", { botId });
expect(updated).toMatchObject({
name: "Weekday check-in",
prompt: "Send the revised update",
crons: ["0 9 * * 1-5"],
timezone: "Asia/Tokyo",
});
expect(updated?.nextRunAt).not.toBeNull();
expect(["Mon", "Tue", "Wed", "Thu", "Fri"]).toContain(
localSchedule(updated!.nextRunAt!, updated!.timezone).weekday,
);
await captureScreenshot(page, testInfo, "routine-weekday-schedule");
await updatedButton.click();
await page.getByRole("button", { name: "Delete", exact: true }).click();
const dialog = page.getByRole("alertdialog", { name: "Delete Weekday check-in?" });
await expect(dialog).toBeVisible();
await dialog.getByRole("button", { name: "Cancel" }).click();
await expect(dialog).toHaveCount(0);
await expect(page.locator("label:has-text('Name') input")).toHaveValue("Weekday check-in");
await page.getByRole("button", { name: "Delete", exact: true }).click();
const removeResponse = page.waitForResponse(
(response) => response.url().includes("/rpc/routines/remove") && response.ok(),
);
await dialog.getByRole("button", { name: "Delete", exact: true }).click();
await removeResponse;
await expect(updatedButton).toHaveCount(0);
expect(await rpc<Routine[]>(page, "routines/list", { botId })).toEqual([]);
await page.reload();
await page.getByTitle("Agent computer").click();
await expect(updatedButton).toHaveCount(0);
});
test("invalid advanced cron is rejected without creating a routine", async ({ page }, testInfo) => {
const stamp = Date.now();
await signup(page, `routine-invalid-${stamp}@rakazo.test`, "password12", "Invalid Routine");
await completeOnboarding(page);
const botId = activeBotId(page);
await page.getByTitle("Agent computer").click();
await page.getByRole("button", { name: "New routine" }).click();
await page.locator("label:has-text('Name') input").fill("Broken schedule");
await page.locator("label:has-text('Instruction') textarea").fill("This should never run");
await addScheduleTrigger(page, "Advanced...");
await page.getByLabel("Cron expression").fill("61 25 * * *");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByRole("alert")).toContainText("Enter a valid cron expression.");
expect(await rpc<Routine[]>(page, "routines/list", { botId })).toEqual([]);
await captureScreenshot(page, testInfo, "invalid-cron-rejected");
});
test("a successful routine create is not reported as failed when refresh fails", async ({
page,
}) => {
const stamp = Date.now();
await signup(page, `routine-refresh-${stamp}@rakazo.test`, "password12", "Routine Refresh");
await completeOnboarding(page);
const botId = activeBotId(page);
await page.getByTitle("Agent computer").click();
await page.getByRole("button", { name: "New routine" }).click();
await page.locator("label:has-text('Name') input").fill("Persisted routine");
await page.locator("label:has-text('Instruction') textarea").fill("Run once each morning");
await addScheduleTrigger(page, "Every day");
await page.route(
"**/rpc/routines/list",
(route) => route.fulfill({ status: 500, body: "refresh failed" }),
{ times: 1 },
);
const createResponse = page.waitForResponse(
(response) => response.url().includes("/rpc/routines/create") && response.ok(),
);
const failedRefresh = page.waitForResponse(
(response) => response.url().includes("/rpc/routines/list") && response.status() === 500,
);
await page.getByRole("button", { name: "Save", exact: true }).click();
await createResponse;
await failedRefresh;
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "routine");
await expect(page.getByRole("alert")).toHaveCount(0);
await page.unroute("**/rpc/routines/list");
const routines = await rpc<Routine[]>(page, "routines/list", { botId });
expect(routines).toHaveLength(1);
expect(routines[0]?.name).toBe("Persisted routine");
});
test("switching bots while a routine save is pending does not reopen stale state", async ({
page,
}) => {
const stamp = Date.now();
await signup(page, `routine-switch-${stamp}@rakazo.test`, "password12", "Routine Switch");
await completeOnboarding(page);
const firstBotId = activeBotId(page);
const secondBot = await rpc<Bot>(page, "bots/create", {
name: "Second",
title: "",
description: "",
instructions: "",
notifyOnFinish: false,
});
await Promise.all([
rpc<Routine>(page, "routines/create", {
botId: firstBotId,
name: "First routine",
prompt: "First prompt",
crons: ["0 9 * * *"],
timezone: "UTC",
active: true,
notify: true,
}),
rpc<Routine>(page, "routines/create", {
botId: secondBot.id,
name: "Second routine",
prompt: "Second prompt",
crons: ["0 9 * * *"],
timezone: "UTC",
active: true,
notify: true,
}),
]);
await page.reload();
await page.getByTitle("Agent computer").click();
await page.getByRole("button", { name: /First routine/ }).click();
await page.locator("label:has-text('Name') input").fill("First routine updated");
let releaseUpdate!: () => void;
let sawUpdate!: () => void;
const updateReleased = new Promise<void>((resolve) => {
releaseUpdate = resolve;
});
const updateIntercepted = new Promise<void>((resolve) => {
sawUpdate = resolve;
});
await page.route(
"**/rpc/routines/update",
async (route) => {
sawUpdate();
await updateReleased;
await route.fulfill({ status: 500, body: "save failed" });
},
{ times: 1 },
);
const updateResponse = page.waitForResponse((response) =>
response.url().includes("/rpc/routines/update"),
);
await page.getByRole("button", { name: "Save", exact: true }).click();
await updateIntercepted;
await page
.locator("aside")
.first()
.getByRole("button", { name: /^Second/ })
.click();
await page.waitForURL(new RegExp(`/app/${secondBot.id}$`));
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "closed");
releaseUpdate();
await updateResponse;
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "closed");
await expect(page.getByRole("alert")).toHaveCount(0);
await page.getByTitle("Agent computer").click();
await expect(page.getByRole("button", { name: /Second routine/ })).toHaveCount(1);
await expect(page.getByRole("button", { name: /First routine/ })).toHaveCount(0);
let releaseStaleList!: () => void;
let sawStaleList!: () => void;
const staleListReleased = new Promise<void>((resolve) => {
releaseStaleList = resolve;
});
const staleListIntercepted = new Promise<void>((resolve) => {
sawStaleList = resolve;
});
await page.route("**/rpc/routines/list", async (route) => {
if (route.request().postData()?.includes(firstBotId) !== true) {
await route.continue();
return;
}
sawStaleList();
await staleListReleased;
await route.continue();
});
const staleListResponse = page.waitForResponse(
(response) =>
response.url().includes("/rpc/routines/list") &&
response.request().postData()?.includes(firstBotId) === true,
);
const botList = page.locator("aside").first();
await botList.getByRole("button", { name: /^Chief/ }).click();
await staleListIntercepted;
await botList.getByRole("button", { name: /^Second/ }).click();
await page.waitForURL(new RegExp(`/app/${secondBot.id}$`));
releaseStaleList();
await staleListResponse;
await page.unroute("**/rpc/routines/list");
await expect(page.getByRole("button", { name: /Second routine/ })).toHaveCount(1);
await expect(page.getByRole("button", { name: /First routine/ })).toHaveCount(0);
});
function localSchedule(iso: string, timezone: string) {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: timezone,
weekday: "short",
hour: "numeric",
minute: "numeric",
hour12: false,
}).formatToParts(new Date(iso));
const value = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((part) => part.type === type)?.value ?? "";
return {
weekday: value("weekday"),
hour: Number(value("hour")) % 24,
minute: Number(value("minute")),
};
}