* feat(web): routine list toggles and hide empty Routinen section Show Routinen in the Oo side panel only when the bot has routines; remove the panel create control. Each list row gets an active switch with optimistic updates via routines.update. E2E covers visibility, toggle persistence, and updates existing routine specs to create via RPC like chat. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): sync lingui catalogs after routine panel UI change Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): refresh routine list optimistically after save Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * test(e2e): wait for routine list after RPC create in golden path Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * style: format golden e2e after routine list wait Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): show sign-up while session lookup is pending Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
339 lines
12 KiB
TypeScript
339 lines
12 KiB
TypeScript
import { expect, type Page, test } from "@playwright/test";
|
|
import type { Bot, Routine } from "@rakazo/contracts";
|
|
import {
|
|
activeBotId,
|
|
captureScreenshot,
|
|
completeOnboarding,
|
|
createNamedBot,
|
|
createTestRoutine,
|
|
openAgentComputerPanel,
|
|
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);
|
|
const botId = activeBotId(page);
|
|
await createTestRoutine(page, botId, { name: "Toggle styling" });
|
|
await openAgentComputerPanel(page);
|
|
await page.getByRole("button", { name: /Toggle styling/ }).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(15, 1);
|
|
await captureScreenshot(page, testInfo, "routine-toggle-desktop-active");
|
|
await toggle.click();
|
|
await expect(toggle).toHaveAttribute("aria-checked", "false");
|
|
await expectThumbInsets(1, 15);
|
|
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).boxShadow)).not.toBe("none");
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await expectThumbInsets(15, 1);
|
|
await captureScreenshot(page, testInfo, "routine-toggle-mobile-active");
|
|
await toggle.click();
|
|
await expectThumbInsets(1, 15);
|
|
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 }) => {
|
|
const stamp = Date.now();
|
|
await signup(page, `routine-invalid-${stamp}@rakazo.test`, "password12", "Invalid Routine");
|
|
await completeOnboarding(page);
|
|
const botId = activeBotId(page);
|
|
|
|
await expect(
|
|
rpc<Routine>(page, "routines/create", {
|
|
botId,
|
|
name: "Broken schedule",
|
|
prompt: "This should never run",
|
|
crons: ["61 25 * * *"],
|
|
timezone: "UTC",
|
|
active: true,
|
|
notify: true,
|
|
webhookEnabled: false,
|
|
githubEnabled: false,
|
|
messageProvider: null,
|
|
}),
|
|
).rejects.toThrow();
|
|
expect(await rpc<Routine[]>(page, "routines/list", { botId })).toEqual([]);
|
|
});
|
|
|
|
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);
|
|
|
|
const created = await createTestRoutine(page, botId, {
|
|
name: "Persisted routine",
|
|
prompt: "Run once each morning",
|
|
});
|
|
await openAgentComputerPanel(page);
|
|
await page.getByRole("button", { name: /Persisted routine/ }).click();
|
|
await page
|
|
.locator("label:has-text('Instruction') textarea")
|
|
.fill("Run once each morning, revised");
|
|
|
|
await page.route(
|
|
"**/rpc/routines/list",
|
|
(route) => route.fulfill({ status: 500, body: "refresh failed" }),
|
|
{ times: 1 },
|
|
);
|
|
const updateResponse = page.waitForResponse(
|
|
(response) => response.url().includes("/rpc/routines/update") && 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 updateResponse;
|
|
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]?.id).toBe(created.id);
|
|
expect(routines[0]?.prompt).toBe("Run once each morning, revised");
|
|
});
|
|
|
|
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);
|
|
// Both bots are roster entries; the main bot is reached through the main conversation.
|
|
const firstBotId = await createNamedBot(page, "First");
|
|
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: /^First/ }).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")),
|
|
};
|
|
}
|