* feat(web): Phase 2 production readiness — locale, mobile, E2E matrix Localize onboarding focus facts by profile locale (DE assertion). Add mobile overflow/touch audits, deep-link matrix, extended console checks, and E2E for empty project, session unavailable, and teaching composer. Safe-area and Heute header layout on 390px viewports. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(e2e): stabilize Phase 2 specs for mobile shell and empty project Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(api): satisfy typecheck for focusFactValue Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(api): type-safe focusFactValue lookup Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(api): narrow focus fact locale key Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(api): use FocusOptionId union for fact lookup Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * test(web): align Shell header control assertions with touch targets Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * style(web): format desktop header control test Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): stabilize Phase 2 E2E (settings i18n, empty project, chart plot load) Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): empty project E2E simulates pre-start bootstrap; relax module recovery wait Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): format empty-project E2E; stabilize chart-links cold Plot load Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * test(web): run chart-links E2E serially to reuse warmed Plot module Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * style(web): format chart-links E2E 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>
380 lines
14 KiB
TypeScript
380 lines
14 KiB
TypeScript
import { expect, type Locator, type Page, type Route, type TestInfo } from "@playwright/test";
|
||
|
||
type RpcEnvelope = { json?: Record<string, unknown> };
|
||
|
||
function isBenignRouteError(error: unknown) {
|
||
return /disposed|already handled|Target closed|Request context disposed/i.test(String(error));
|
||
}
|
||
|
||
/** Unterhaltungen rows only — `.sidebar-projekte` is nested inside the same shell block on main. */
|
||
export function unterhaltungenOnly(page: Page) {
|
||
return page.locator(".sidebar-unterhaltungen").locator(":scope > *:not(.sidebar-projekte)");
|
||
}
|
||
|
||
export function isRealSandboxProvider(provider = process.env.SANDBOX_PROVIDER) {
|
||
return provider === "e2b" || provider === "daytona" || provider === "box";
|
||
}
|
||
|
||
export function realSandboxTimeout(real: number, emulated: number) {
|
||
if (process.env.SANDBOX_PROVIDER === "box") return Math.max(real, 300_000);
|
||
return isRealSandboxProvider() ? real : emulated;
|
||
}
|
||
|
||
export function activeBotId(page: Page) {
|
||
const id = new URL(page.url()).pathname.split("/").filter(Boolean).at(-1);
|
||
if (!id || id === "app") throw new Error(`missing bot id in ${page.url()}`);
|
||
return id;
|
||
}
|
||
|
||
export async function rpc<T>(page: Page, procedure: string, body: unknown): Promise<T> {
|
||
const spaceId = await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"));
|
||
const response = await page.request.post(`/rpc/${procedure}`, {
|
||
data: { json: body },
|
||
headers: spaceId ? { "x-rakazo-space-id": spaceId } : undefined,
|
||
});
|
||
const parsed = (await response.json()) as { json?: T; error?: { message?: string } };
|
||
if (!response.ok() || parsed.error) {
|
||
throw new Error(`${procedure} ${response.status()}: ${parsed.error?.message ?? "failed"}`);
|
||
}
|
||
return parsed.json as T;
|
||
}
|
||
|
||
/** Read status and headers before the body so Playwright does not dispose the response early. */
|
||
export async function fetchRpcEnvelope(route: Route) {
|
||
const response = await route.fetch();
|
||
const status = response.status();
|
||
const headers = response.headers();
|
||
const body = (await response.json()) as RpcEnvelope;
|
||
return { status, headers, body };
|
||
}
|
||
|
||
/** Fulfill a fetched RPC response without passing a consumed `response` into `route.fulfill`. */
|
||
export async function fulfillFetchedRpc(
|
||
route: Route,
|
||
fetched: { status: number; headers: { [key: string]: string }; body: RpcEnvelope },
|
||
) {
|
||
await route.fulfill({
|
||
status: fetched.status,
|
||
headers: fetched.headers,
|
||
contentType: "application/json",
|
||
body: JSON.stringify(fetched.body),
|
||
});
|
||
}
|
||
|
||
/** Patch a fetched RPC response without passing a consumed `response` into `route.fulfill`. */
|
||
export async function routePatchRpc(
|
||
page: Page,
|
||
procedure: string,
|
||
patch: (json: Record<string, unknown>) => Record<string, unknown>,
|
||
) {
|
||
await page.route(`**/rpc/${procedure}`, async (route) => {
|
||
try {
|
||
const fetched = await fetchRpcEnvelope(route);
|
||
const json = fetched.body.json;
|
||
if (!json) {
|
||
await fulfillFetchedRpc(route, fetched);
|
||
return;
|
||
}
|
||
await fulfillFetchedRpc(route, { ...fetched, body: { json: patch(json) } });
|
||
} catch (error) {
|
||
if (isBenignRouteError(error)) return;
|
||
throw error;
|
||
}
|
||
});
|
||
}
|
||
|
||
async function visible(locator: Locator, timeout: number) {
|
||
return locator.isVisible({ timeout }).catch(() => false);
|
||
}
|
||
|
||
const onboardingContinueButton = /^(Continue|Weiter)$/i;
|
||
const onboardingSkipButton = /^(Skip|Überspringen)$/i;
|
||
const onboardingLanguageSkipButton = /^(Skip|Überspringen) ·/i;
|
||
|
||
/** After the look picker, skip the optional Oo naming step (keeps the default name). */
|
||
export async function skipOnboardingNameStep(page: Page) {
|
||
await expect(page.getByTestId("onboarding-name-step")).toBeVisible();
|
||
await page.getByRole("button", { name: onboardingSkipButton }).click();
|
||
}
|
||
|
||
/**
|
||
* Walks the first-run wizard, which lands in the main conversation with the main bot's first
|
||
* onboarding question pending (the composer works regardless). Unless `mainConversation` is
|
||
* set, it then opens the main bot's own thread, where most specs start.
|
||
*/
|
||
export async function completeOnboarding(
|
||
page: Page,
|
||
testInfo?: TestInfo,
|
||
options: { mainConversation?: boolean; ooName?: string } = {},
|
||
) {
|
||
await page.waitForURL(/\/(onboarding|app)/, { timeout: 20_000 });
|
||
|
||
if (page.url().includes("/onboarding")) {
|
||
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
|
||
"data-onboarding-step",
|
||
/^(emoji|name|language|voice|integrations|scan|bot)$/,
|
||
{ timeout: 30_000 },
|
||
);
|
||
}
|
||
|
||
const design = page.getByRole("heading", { name: /Design your Oo|Gestalte dein Oo/i });
|
||
const naming = page.getByTestId("onboarding-name-step");
|
||
const language = page.getByRole("heading", {
|
||
name: /Choose your language|Wähle deine Sprache/i,
|
||
});
|
||
const voice = page.getByRole("heading", { name: /Pick a voice|Wähle eine Stimme/i });
|
||
const integrations = page.getByRole("heading", {
|
||
name: /Server integrations|Server-Integrationen/i,
|
||
});
|
||
const expectedOoName = options.ooName ?? "Chief";
|
||
const chief = page.getByText(expectedOoName).first();
|
||
|
||
// Scripted CI has needsModel false, so a fresh owner starts on integrations.
|
||
// A model-gated run still opens the look picker first.
|
||
const entry = design.or(integrations).or(chief);
|
||
await expect(entry.first()).toBeVisible({ timeout: 20_000 });
|
||
|
||
if (await design.isVisible()) {
|
||
await page.getByRole("button", { name: onboardingContinueButton }).click();
|
||
await expect(naming).toBeVisible();
|
||
if (options.ooName) {
|
||
await page.getByTestId("onboarding-oo-name-input").fill(options.ooName);
|
||
await naming.getByRole("button", { name: onboardingContinueButton }).click();
|
||
} else {
|
||
await page.getByRole("button", { name: onboardingSkipButton }).click();
|
||
}
|
||
await expect(language).toBeVisible();
|
||
await page.getByRole("button", { name: onboardingLanguageSkipButton }).click();
|
||
await expect(voice).toBeVisible();
|
||
await page.getByRole("button", { name: onboardingSkipButton }).click();
|
||
}
|
||
|
||
if (await visible(integrations, 8_000)) {
|
||
if (testInfo) await captureScreenshot(page, testInfo, "02-connect-apps");
|
||
await page.getByRole("button", { name: onboardingSkipButton }).click();
|
||
}
|
||
|
||
// The scan step, when shown, either asks first (skip it) or advances by itself. Onboarding
|
||
// then lands in the main conversation.
|
||
const skipScan = page
|
||
.locator('[data-onboarding-step="scan"]')
|
||
.getByRole("button", { name: onboardingSkipButton });
|
||
await expect(async () => {
|
||
if (await skipScan.isVisible()) await skipScan.click();
|
||
expect(page.url()).toMatch(/\/app\/g\/[^/]+$/);
|
||
}).toPass({ timeout: 30_000 });
|
||
await expect(chief).toBeVisible({ timeout: 15_000 });
|
||
await waitForChatComposer(page);
|
||
|
||
if (testInfo) {
|
||
await captureScreenshot(page, testInfo, "03-create-first-bot");
|
||
await captureScreenshot(page, testInfo, "06-onboarding-complete");
|
||
}
|
||
if (!options.mainConversation) await openMainBotThread(page);
|
||
}
|
||
|
||
/** The main bot's own thread stays reachable by URL; the sidebar lists the main conversation. */
|
||
export async function openMainBotThread(page: Page) {
|
||
const main = await rpc<{ id: string }>(page, "bots/ensureMain", {});
|
||
await page.goto(`/app/${main.id}`, { waitUntil: "domcontentloaded" });
|
||
await page.waitForURL(new RegExp(`/app/${main.id}$`), {
|
||
timeout: 20_000,
|
||
waitUntil: "domcontentloaded",
|
||
});
|
||
await waitForChatComposer(page);
|
||
return main.id;
|
||
}
|
||
|
||
export async function signup(
|
||
page: Page,
|
||
email: string,
|
||
password: string,
|
||
name: string,
|
||
testInfo?: TestInfo,
|
||
) {
|
||
await expect(async () => {
|
||
await page.goto("/sign-up", { waitUntil: "domcontentloaded" });
|
||
await expect(
|
||
page.getByRole("heading", { name: /Create your evOo|Dein evOo erstellen/i }),
|
||
).toBeVisible({
|
||
timeout: 5_000,
|
||
});
|
||
}).toPass({ timeout: 45_000 });
|
||
if (testInfo) await captureScreenshot(page, testInfo, "01-sign-up");
|
||
await page.getByPlaceholder(/Your name|Dein Name/i).fill(name);
|
||
await page.getByPlaceholder(/Your email address|Deine E-Mail-Adresse/i).fill(email);
|
||
await page.getByPlaceholder(/^Password$|^Passwort$/i).fill(password);
|
||
await page.getByRole("button", { name: /Create account|Konto erstellen/i }).click();
|
||
}
|
||
|
||
export async function captureScreenshot(page: Page, testInfo: TestInfo, name: string) {
|
||
const screenshotPath = testInfo.outputPath(`${name}.png`);
|
||
await page.screenshot({
|
||
animations: "disabled",
|
||
caret: "hide",
|
||
fullPage: true,
|
||
path: screenshotPath,
|
||
});
|
||
await testInfo.attach(name, { contentType: "image/png", path: screenshotPath });
|
||
}
|
||
|
||
export async function openNewBot(page: Page) {
|
||
await page.getByTestId("create-menu-trigger").click();
|
||
await page.getByTestId("create-new-bot").click();
|
||
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "create");
|
||
await expect(page.getByTestId("create-bot-form")).toBeVisible();
|
||
}
|
||
|
||
export async function openNewGroup(page: Page) {
|
||
await page.getByTestId("create-menu-trigger").click();
|
||
await page.getByTestId("create-new-group").click();
|
||
}
|
||
|
||
export async function openNewSpace(page: Page) {
|
||
await page.getByTestId("create-menu-trigger").click();
|
||
await page.getByTestId("create-new-space").click();
|
||
}
|
||
|
||
/** Open the create form from the + picker, submit, and wait for the new chat. */
|
||
export async function createBotFromPicker(
|
||
page: Page,
|
||
options: {
|
||
name?: string;
|
||
title?: string;
|
||
description?: string;
|
||
computerMode?: "team" | "dedicated";
|
||
} = {},
|
||
) {
|
||
const name = options.name ?? "New Bot";
|
||
await openNewBot(page);
|
||
const form = page.getByTestId("create-bot-form");
|
||
await form.locator("label:has-text('Name') input").fill(name);
|
||
if (options.title != null) {
|
||
await form.locator("label:has-text('Title') input").fill(options.title);
|
||
}
|
||
if (options.description != null) {
|
||
await form.locator("label:has-text('Description') textarea").fill(options.description);
|
||
}
|
||
if (options.computerMode === "dedicated") {
|
||
await form.getByTestId("create-bot-private").click();
|
||
} else if (options.computerMode === "team") {
|
||
await form.getByTestId("create-bot-team").click();
|
||
}
|
||
await form.getByRole("button", { name: "Create", exact: true }).click();
|
||
await page.waitForURL(/\/app\/[^/]+$/);
|
||
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "closed");
|
||
}
|
||
|
||
/** Open the user Settings overlay, optionally switching to a sidebar section. */
|
||
export async function openUserSettings(
|
||
page: Page,
|
||
section?:
|
||
| "general"
|
||
| "integrations"
|
||
| "models"
|
||
| "memory"
|
||
| "voice"
|
||
| "notifications"
|
||
| "usage"
|
||
| "computer"
|
||
| "updates"
|
||
| "privacy",
|
||
) {
|
||
await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", {
|
||
timeout: 60_000,
|
||
});
|
||
const openNav = page.getByTestId("open-navigation");
|
||
if (await openNav.isVisible().catch(() => false)) {
|
||
await openNav.click();
|
||
}
|
||
const menuTrigger = page.getByTestId("user-menu-trigger");
|
||
await expect(menuTrigger).toBeVisible({ timeout: 30_000 });
|
||
await menuTrigger.scrollIntoViewIfNeeded();
|
||
await menuTrigger.click();
|
||
await page.getByRole("button", { name: /^Settings$|^Einstellungen$/i }).click();
|
||
const settings = page.getByTestId("user-settings");
|
||
await expect(settings).toBeVisible();
|
||
if (section && section !== "general") {
|
||
await settings.getByTestId(`settings-nav-${section}`).click();
|
||
}
|
||
return settings;
|
||
}
|
||
|
||
/** Wait until the shell thread is loaded and the message composer is interactive. */
|
||
export async function waitForChatComposer(page: Page) {
|
||
await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", {
|
||
timeout: 30_000,
|
||
});
|
||
await expect(page.getByRole("combobox", { name: /^Message$|^Nachricht$/i })).toBeVisible();
|
||
}
|
||
|
||
/** Create a named bot via RPC for test setup (skips the + picker). */
|
||
export async function createNamedBot(
|
||
page: Page,
|
||
name: string,
|
||
options: { computerMode?: "team" | "dedicated" } = {},
|
||
) {
|
||
const bot = await rpc<{ id: string; name: string }>(page, "bots/create", {
|
||
name,
|
||
title: "",
|
||
description: "",
|
||
notifyOnFinish: true,
|
||
computerMode: options.computerMode ?? "team",
|
||
});
|
||
await page.goto(`/app/${bot.id}`, { waitUntil: "domcontentloaded" });
|
||
await page.waitForURL(/\/app\/[^/]+$/, { timeout: 20_000 });
|
||
await waitForChatComposer(page);
|
||
return bot.id;
|
||
}
|
||
|
||
export const ALLOWED_CONSOLE_ERRORS = [
|
||
/Failed to load resource.*404/i,
|
||
/Failed to load resource.*401/i,
|
||
/favicon/i,
|
||
/ERR_ABORTED/i,
|
||
/Content Security Policy/i,
|
||
/net::ERR_/i,
|
||
/React Router Future Flag/i,
|
||
/DevTools failed to load SourceMap/i,
|
||
];
|
||
|
||
/** Collect unexpected console errors and page errors for the rest of the test. */
|
||
export function attachConsoleGuard(page: Page) {
|
||
const errors: string[] = [];
|
||
page.on("console", (msg) => {
|
||
if (msg.type() !== "error") return;
|
||
const text = msg.text();
|
||
if (ALLOWED_CONSOLE_ERRORS.some((pattern) => pattern.test(text))) return;
|
||
errors.push(text);
|
||
});
|
||
page.on("pageerror", (err) => errors.push(err.message));
|
||
return errors;
|
||
}
|
||
|
||
/** True when the document does not scroll horizontally (±1px tolerance). */
|
||
export async function assertNoHorizontalOverflow(page: Page) {
|
||
const ok = await page.evaluate(() => {
|
||
const doc = document.documentElement;
|
||
return doc.scrollWidth <= doc.clientWidth + 1;
|
||
});
|
||
expect(ok, `horizontal overflow on ${page.url()}`).toBe(true);
|
||
}
|
||
|
||
const MOBILE_TOUCH_MIN = 44;
|
||
|
||
/**
|
||
* Primary chrome controls must meet 44×44px on touch viewports, either by box size or `rk-touch-target`.
|
||
*/
|
||
export async function assertPrimaryTouchTargets(page: Page, selectors: string[]) {
|
||
for (const selector of selectors) {
|
||
const locator = page.locator(selector).first();
|
||
await expect(locator, selector).toBeVisible();
|
||
const ok = await locator.evaluate((node, min) => {
|
||
const rect = node.getBoundingClientRect();
|
||
if (rect.width >= min && rect.height >= min) return true;
|
||
return node.classList.contains("rk-touch-target");
|
||
}, MOBILE_TOUCH_MIN);
|
||
expect(ok, `${selector} touch target`).toBe(true);
|
||
}
|
||
}
|