Compare commits
No commits in common. "22000ad2adc6cba6a21d17a8b814dffd1b1352d6" and "3a3e0b3e27de06e826c6d970c4db26f5317e7155" have entirely different histories.
22000ad2ad
...
3a3e0b3e27
@ -43,16 +43,14 @@ interface IntakeStep {
|
|||||||
save: (effects: IntakeEffects, answer: IntakeAnswer) => Promise<void>;
|
save: (effects: IntakeEffects, answer: IntakeAnswer) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FocusOptionId = "day" | "inbox" | "research" | "crew";
|
|
||||||
|
|
||||||
export interface FocusOption {
|
export interface FocusOption {
|
||||||
id: FocusOptionId;
|
id: string;
|
||||||
letter: string;
|
letter: string;
|
||||||
label: string;
|
label: string;
|
||||||
apps: string[];
|
apps: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const FOCUS_FACTS = {
|
const FOCUS_FACTS: Record<string, Record<FocusOption["id"], string>> = {
|
||||||
en: {
|
en: {
|
||||||
day: "Wants help with day-to-day work first",
|
day: "Wants help with day-to-day work first",
|
||||||
inbox: "Wants help with inbox and email first",
|
inbox: "Wants help with inbox and email first",
|
||||||
@ -65,17 +63,14 @@ const FOCUS_FACTS = {
|
|||||||
research: "Möchte zuerst bei Recherche und Schreiben unterstützt werden",
|
research: "Möchte zuerst bei Recherche und Schreiben unterstützt werden",
|
||||||
crew: "Möchte eine koordinierte Crew aus Spezialisten-Bots",
|
crew: "Möchte eine koordinierte Crew aus Spezialisten-Bots",
|
||||||
},
|
},
|
||||||
} as const satisfies Record<string, Record<FocusOptionId, string>>;
|
};
|
||||||
|
|
||||||
/** Profile fact text for a focus answer, in the user's locale (defaults to English). */
|
/** Profile fact text for a focus answer, in the user's locale (defaults to English). */
|
||||||
function isFocusOptionId(id: string): id is FocusOptionId {
|
|
||||||
return FOCUS_OPTIONS.some((option) => option.id === id);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function focusFactValue(focusId: string, locale: string): string {
|
export function focusFactValue(focusId: string, locale: string): string {
|
||||||
if (!isFocusOptionId(focusId)) return focusId;
|
const id = focusId as FocusOption["id"];
|
||||||
const key: keyof typeof FOCUS_FACTS = locale.toLowerCase().startsWith("de") ? "de" : "en";
|
const key = locale.toLowerCase().startsWith("de") ? "de" : "en";
|
||||||
return FOCUS_FACTS[key][focusId];
|
const table = FOCUS_FACTS[key];
|
||||||
|
return table[id] ?? FOCUS_FACTS.en[id] ?? focusId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FOCUS_OPTIONS: FocusOption[] = [
|
export const FOCUS_OPTIONS: FocusOption[] = [
|
||||||
|
|||||||
@ -3,91 +3,89 @@ import { captureScreenshot } from "./helpers";
|
|||||||
|
|
||||||
const fixture = "/e2e/fixtures/chart-links.html";
|
const fixture = "/e2e/fixtures/chart-links.html";
|
||||||
|
|
||||||
test.describe
|
test("chart links cannot execute script when clicked or expanded", async ({ page }, testInfo) => {
|
||||||
.serial("chart links", () => {
|
test.setTimeout(120_000);
|
||||||
test("cannot execute script when clicked or expanded", async ({ page }, testInfo) => {
|
// ChartCanvas lazy-loads Observable Plot; pay the import cost once before the matrix below.
|
||||||
test.setTimeout(120_000);
|
await page.goto(`${fixture}?${new URLSearchParams({ href: "#details", inspect: "0" })}`);
|
||||||
// ChartCanvas lazy-loads Observable Plot; pay the import cost once before the matrix below.
|
await expect(page.locator("svg circle").first()).toBeVisible({ timeout: 90_000 });
|
||||||
await page.goto(`${fixture}?${new URLSearchParams({ href: "#details", inspect: "0" })}`);
|
for (const inspect of ["0", "1"]) {
|
||||||
await expect(page.locator("svg circle").first()).toBeVisible({ timeout: 90_000 });
|
for (const href of [
|
||||||
for (const inspect of ["0", "1"]) {
|
'javascript:void(document.body.dataset.plotExecuted="yes")',
|
||||||
for (const href of [
|
' \u0000\u001fJaVa\tScRi\nPt:void(document.body.dataset.plotExecuted="yes")',
|
||||||
'javascript:void(document.body.dataset.plotExecuted="yes")',
|
'javascript:%76oid(document.body.dataset.plotExecuted="yes")',
|
||||||
' \u0000\u001fJaVa\tScRi\nPt:void(document.body.dataset.plotExecuted="yes")',
|
"data:text/html,<script>document.body.dataset.plotExecuted='yes'</script>",
|
||||||
'javascript:%76oid(document.body.dataset.plotExecuted="yes")',
|
"file:///example.txt",
|
||||||
"data:text/html,<script>document.body.dataset.plotExecuted='yes'</script>",
|
]) {
|
||||||
"file:///example.txt",
|
await page.goto(`${fixture}?${new URLSearchParams({ href, inspect })}`);
|
||||||
]) {
|
const circle = page.locator("svg circle").first();
|
||||||
await page.goto(`${fixture}?${new URLSearchParams({ href, inspect })}`);
|
// Every navigation renders Plot again, which takes seconds on a loaded CI runner.
|
||||||
const circle = page.locator("svg circle").first();
|
await expect(circle).toBeVisible({ timeout: 30_000 });
|
||||||
// Every navigation renders Plot again, which takes seconds on a loaded CI runner.
|
const assertInert = async () => {
|
||||||
await expect(circle).toBeVisible({ timeout: 30_000 });
|
const links = page.locator("svg a");
|
||||||
const assertInert = async () => {
|
expect(await links.count()).toBeGreaterThan(0);
|
||||||
const links = page.locator("svg a");
|
|
||||||
expect(await links.count()).toBeGreaterThan(0);
|
|
||||||
expect(
|
|
||||||
await links.evaluateAll((anchors) =>
|
|
||||||
anchors.every(
|
|
||||||
(anchor) =>
|
|
||||||
!anchor.hasAttribute("href") &&
|
|
||||||
!anchor.hasAttributeNS("http://www.w3.org/1999/xlink", "href"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
await expect(page.locator("body")).not.toHaveAttribute("data-plot-executed");
|
|
||||||
};
|
|
||||||
await assertInert();
|
|
||||||
await circle.click();
|
|
||||||
// Hover inspection still works, without reintroducing unsafe links.
|
|
||||||
if (inspect === "1") await expect(page.locator('[aria-label="tip"]')).toBeVisible();
|
|
||||||
await assertInert();
|
|
||||||
await page.getByRole("button", { name: "Expand", exact: true }).click();
|
|
||||||
const dialog = page.getByRole("dialog");
|
|
||||||
await expect(dialog.locator("svg circle")).toBeVisible({ timeout: 30_000 });
|
|
||||||
await dialog.locator("svg circle").click();
|
|
||||||
await assertInert();
|
|
||||||
await expect(page).toHaveURL(`${fixture}?${new URLSearchParams({ href, inspect })}`);
|
|
||||||
await page.getByRole("button", { name: "Close chart" }).click();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await captureScreenshot(page, testInfo, "chart-with-inert-link");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("retain supported URLs and browser encoding semantics", async ({ page }) => {
|
|
||||||
test.setTimeout(120_000);
|
|
||||||
await page.route("https://example.test/**", (route) =>
|
|
||||||
route.fulfill({ body: "Chart destination" }),
|
|
||||||
);
|
|
||||||
await page.route("**/e2e/fixtures/java*", (route) =>
|
|
||||||
route.fulfill({ body: "Chart destination" }),
|
|
||||||
);
|
|
||||||
await page.goto(`${fixture}?${new URLSearchParams({ href: "#details" })}`);
|
|
||||||
await expect(page.locator("svg circle").first()).toBeVisible({ timeout: 30_000 });
|
|
||||||
for (const href of [
|
|
||||||
"https://example.test/chart?a=1&b=2",
|
|
||||||
"mailto:chart@example.test",
|
|
||||||
"tel:+15555550123",
|
|
||||||
"#details",
|
|
||||||
"javascript:void(0)",
|
|
||||||
"javascript%3Avoid(0)",
|
|
||||||
"java%73cript:void(0)",
|
|
||||||
]) {
|
|
||||||
await page.goto(`${fixture}?${new URLSearchParams({ href })}`);
|
|
||||||
await expect(page.locator("svg circle").first()).toBeVisible({ timeout: 30_000 });
|
|
||||||
const link = page.locator("svg a").first();
|
|
||||||
await expect(link).toBeAttached();
|
|
||||||
expect(
|
expect(
|
||||||
await link.evaluate((anchor) =>
|
await links.evaluateAll((anchors) =>
|
||||||
anchor.getAttributeNS("http://www.w3.org/1999/xlink", "href"),
|
anchors.every(
|
||||||
|
(anchor) =>
|
||||||
|
!anchor.hasAttribute("href") &&
|
||||||
|
!anchor.hasAttributeNS("http://www.w3.org/1999/xlink", "href"),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
).toBe(href);
|
).toBe(true);
|
||||||
const destination = new URL(href, page.url());
|
await expect(page.locator("body")).not.toHaveAttribute("data-plot-executed");
|
||||||
expect(destination.protocol).not.toBe("javascript:");
|
};
|
||||||
if (destination.protocol === "http:" || destination.protocol === "https:") {
|
await assertInert();
|
||||||
await link.locator("circle").click();
|
await circle.click();
|
||||||
await expect(page).toHaveURL(destination.href);
|
// Hover inspection still works, without reintroducing unsafe links.
|
||||||
await expect(page.locator("body")).not.toHaveAttribute("data-plot-executed");
|
if (inspect === "1") await expect(page.locator('[aria-label="tip"]')).toBeVisible();
|
||||||
}
|
await assertInert();
|
||||||
}
|
await page.getByRole("button", { name: "Expand", exact: true }).click();
|
||||||
});
|
const dialog = page.getByRole("dialog");
|
||||||
});
|
await expect(dialog.locator("svg circle")).toBeVisible({ timeout: 30_000 });
|
||||||
|
await dialog.locator("svg circle").click();
|
||||||
|
await assertInert();
|
||||||
|
await expect(page).toHaveURL(`${fixture}?${new URLSearchParams({ href, inspect })}`);
|
||||||
|
await page.getByRole("button", { name: "Close chart" }).click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await captureScreenshot(page, testInfo, "chart-with-inert-link");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chart links retain supported URLs and browser encoding semantics", async ({ page }) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
await page.route("https://example.test/**", (route) =>
|
||||||
|
route.fulfill({ body: "Chart destination" }),
|
||||||
|
);
|
||||||
|
await page.route("**/e2e/fixtures/java*", (route) =>
|
||||||
|
route.fulfill({ body: "Chart destination" }),
|
||||||
|
);
|
||||||
|
// Each test gets a fresh browser context, so this one pays the cold Plot import too.
|
||||||
|
await page.goto(`${fixture}?${new URLSearchParams({ href: "#details" })}`);
|
||||||
|
await expect(page.locator("svg circle").first()).toBeVisible({ timeout: 90_000 });
|
||||||
|
for (const href of [
|
||||||
|
"https://example.test/chart?a=1&b=2",
|
||||||
|
"mailto:chart@example.test",
|
||||||
|
"tel:+15555550123",
|
||||||
|
"#details",
|
||||||
|
"javascript:void(0)",
|
||||||
|
"javascript%3Avoid(0)",
|
||||||
|
"java%73cript:void(0)",
|
||||||
|
]) {
|
||||||
|
await page.goto(`${fixture}?${new URLSearchParams({ href })}`);
|
||||||
|
await expect(page.locator("svg circle").first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
const link = page.locator("svg a").first();
|
||||||
|
await expect(link).toBeAttached();
|
||||||
|
expect(
|
||||||
|
await link.evaluate((anchor) =>
|
||||||
|
anchor.getAttributeNS("http://www.w3.org/1999/xlink", "href"),
|
||||||
|
),
|
||||||
|
).toBe(href);
|
||||||
|
const destination = new URL(href, page.url());
|
||||||
|
expect(destination.protocol).not.toBe("javascript:");
|
||||||
|
if (destination.protocol === "http:" || destination.protocol === "https:") {
|
||||||
|
await link.locator("circle").click();
|
||||||
|
await expect(page).toHaveURL(destination.href);
|
||||||
|
await expect(page.locator("body")).not.toHaveAttribute("data-plot-executed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { completeOnboarding, routePatchRpc, rpc, signup } from "./helpers";
|
import { completeOnboarding, rpc, signup } from "./helpers";
|
||||||
|
|
||||||
test("empty project space offers Start before the main conversation exists", async ({ page }) => {
|
test("empty project space offers Start before the main conversation exists", async ({ page }) => {
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
@ -7,41 +7,15 @@ test("empty project space offers Start before the main conversation exists", asy
|
|||||||
await completeOnboarding(page);
|
await completeOnboarding(page);
|
||||||
|
|
||||||
const side = await rpc<{ id: string }>(page, "spaces/create", { name: "Empty project" });
|
const side = await rpc<{ id: string }>(page, "spaces/create", { name: "Empty project" });
|
||||||
|
|
||||||
// spaces/create provisions a main conversation server-side; strip it from bootstrap so the
|
|
||||||
// shell shows the pre-start empty project UI.
|
|
||||||
await routePatchRpc(page, "bootstrap", (json) => {
|
|
||||||
const groups = (json.groups as Array<{ spaceId: string }>).filter(
|
|
||||||
(group) => group.spaceId !== side.id,
|
|
||||||
);
|
|
||||||
const bots = (json.bots as Array<{ spaceId: string }>).filter((bot) => bot.spaceId !== side.id);
|
|
||||||
const spaces = (
|
|
||||||
json.spaces as Array<{
|
|
||||||
id: string;
|
|
||||||
bots: unknown[];
|
|
||||||
groups: unknown[];
|
|
||||||
hasContent: boolean;
|
|
||||||
}>
|
|
||||||
).map((space) =>
|
|
||||||
space.id === side.id ? { ...space, bots: [], groups: [], hasContent: false } : space,
|
|
||||||
);
|
|
||||||
return { ...json, groups, bots, spaces };
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.evaluate((spaceId) => {
|
await page.evaluate((spaceId) => {
|
||||||
window.localStorage.setItem("rakazo:space-id", spaceId);
|
window.localStorage.setItem("rakazo:space-id", spaceId);
|
||||||
}, side.id);
|
}, side.id);
|
||||||
await page.goto("/app", { waitUntil: "domcontentloaded" });
|
await page.reload({ waitUntil: "domcontentloaded" });
|
||||||
await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", {
|
await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", {
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const mainStart = page.getByTestId("project-start-main");
|
const sidebarStart = page.getByTestId(`project-start-${side.id}`);
|
||||||
await expect(mainStart).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expect(mainStart).toContainText(/Start|Starten/i);
|
|
||||||
|
|
||||||
const projekte = page.locator(".sidebar-projekte");
|
|
||||||
const sidebarStart = projekte.getByTestId(`project-start-${side.id}`);
|
|
||||||
await expect(sidebarStart).toBeVisible({ timeout: 30_000 });
|
await expect(sidebarStart).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(sidebarStart).toContainText(/Start|Starten/i);
|
await expect(sidebarStart).toContainText(/Start|Starten/i);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -291,7 +291,7 @@ export async function openUserSettings(
|
|||||||
await expect(menuTrigger).toBeVisible({ timeout: 30_000 });
|
await expect(menuTrigger).toBeVisible({ timeout: 30_000 });
|
||||||
await menuTrigger.scrollIntoViewIfNeeded();
|
await menuTrigger.scrollIntoViewIfNeeded();
|
||||||
await menuTrigger.click();
|
await menuTrigger.click();
|
||||||
await page.getByRole("button", { name: /^Settings$|^Einstellungen$/i }).click();
|
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||||
const settings = page.getByTestId("user-settings");
|
const settings = page.getByTestId("user-settings");
|
||||||
await expect(settings).toBeVisible();
|
await expect(settings).toBeVisible();
|
||||||
if (section && section !== "general") {
|
if (section && section !== "general") {
|
||||||
|
|||||||
@ -20,9 +20,7 @@ test("the app reloads itself once when a module request aborts while booting", a
|
|||||||
|
|
||||||
await page.goto("/sign-in", { waitUntil: "commit" });
|
await page.goto("/sign-in", { waitUntil: "commit" });
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "Sign in to evOo" })).toBeVisible({
|
await expect(page.getByRole("heading", { name: "Sign in to evOo" })).toBeVisible();
|
||||||
timeout: 60_000,
|
|
||||||
});
|
|
||||||
expect(counter.aborted).toBe(1);
|
expect(counter.aborted).toBe(1);
|
||||||
expect(await page.evaluate(() => sessionStorage.getItem("rakazo:boot-reload"))).toBeNull();
|
expect(await page.evaluate(() => sessionStorage.getItem("rakazo:boot-reload"))).toBeNull();
|
||||||
});
|
});
|
||||||
@ -32,9 +30,7 @@ test("a lazy route recovers when its module request aborts", async ({ page }) =>
|
|||||||
|
|
||||||
await page.goto("/sign-in");
|
await page.goto("/sign-in");
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "Sign in to evOo" })).toBeVisible({
|
await expect(page.getByRole("heading", { name: "Sign in to evOo" })).toBeVisible();
|
||||||
timeout: 60_000,
|
|
||||||
});
|
|
||||||
expect(counter.aborted).toBe(1);
|
expect(counter.aborted).toBe(1);
|
||||||
expect(
|
expect(
|
||||||
await page.evaluate(() => sessionStorage.getItem("rakazo:module-reload:pages/Auth")),
|
await page.evaluate(() => sessionStorage.getItem("rakazo:module-reload:pages/Auth")),
|
||||||
|
|||||||
@ -67,15 +67,12 @@ describe("window chrome", () => {
|
|||||||
expect(shell).toContain(
|
expect(shell).toContain(
|
||||||
'className="app-drag flex items-center justify-between border-b border-sidebar-border',
|
'className="app-drag flex items-center justify-between border-b border-sidebar-border',
|
||||||
);
|
);
|
||||||
expect(shell).toContain(
|
expect(shell).toContain('className="app-no-drag grid size-control-lg');
|
||||||
'className="app-no-drag rk-touch-target grid size-control-lg min-h-control-lg min-w-control-lg',
|
|
||||||
);
|
|
||||||
expect(shell).toContain('className="app-no-drag flex min-w-0 items-center gap-3"');
|
expect(shell).toContain('className="app-no-drag flex min-w-0 items-center gap-3"');
|
||||||
// Ensure header sidebar toggle controls stay clickable (not part of drag region)
|
// Ensure header sidebar toggle controls stay clickable (not part of drag region)
|
||||||
expect(
|
expect(
|
||||||
toggles.match(/app-no-drag rk-touch-target .* size-control-lg .* place-items-center/g)
|
toggles.match(/app-no-drag .* size-control-lg .* place-items-center/g)?.length ?? 0,
|
||||||
?.length ?? 0,
|
).toBeGreaterThanOrEqual(2);
|
||||||
).toBeGreaterThanOrEqual(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("moves window chrome into the conversation header when the bots sidebar is collapsed", () => {
|
it("moves window chrome into the conversation header when the bots sidebar is collapsed", () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user