* chore: remove legacy orchestrator, trading and one-off script leftovers The repository still carried the pre-evOo prototype: a Python orchestrator, the old HTML "evoo-platform" cockpit (whose test ran in the root unit suite), an apex-trading package, the hot-reload deploy.sh for it, a permissions manifest, an architecture note with local file links, agent handoff notes, a Steve Jobs persona template and about ninety one-off scripts that probe a developer database with hard-coded local URLs. None of it is used by the product, CI or the real deploy in ops/evo-deploy. Delete those files, drop the evoo-platform test glob from the root vitest config, the Cockpit section from .env.example and the dead biome and gitignore entries, and update the docs that listed the removed tools. scripts/start-tmux.sh no longer starts the removed sentinel and resolves the checkout from its own location instead of a machine-specific path; scripts/seed-ollama.ts requires DATABASE_URL instead of falling back to a local URL with a username. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * fix(web): drop trading and persona prompts from the conversation shell The web shell still showed leftovers from the trading prototype: composer chips with German trading and "Steve Jobs" prompts, trading suggestion chips under the empty conversation line, a German trading hero for the empty transcript, and a German "data protection" status panel in the sidebar. They suggested actions the product does not have and added persistent chrome. Remove the composer chips and their helper, the empty-state chips and the hero (the empty conversation keeps its existing one-line start hint), the sidebar trading indicator and the data trust panel. The new-bot E2E now asserts the plain empty state; the sidebar spec no longer expects the trading status, and the placeholder Steve Jobs spec with no assertions is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * refactor: remove the trading bot status integration With the sidebar trading indicator gone, the only remaining consumer of the trading bot status was a banner in General settings that reused the same component. evOo is a personal assistant; a status proxy and poller for one external trading service, configured through service-specific TRADING_BOT_URL and VITE_TRADING_BOT_URL variables, does not belong in the core product. Remove the settings banner and its prop plumbing, the web poller, the indicator component, the /api/trading-bot/status proxy, the shared core contract, the env loading and both variables from .env.example, together with their tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * chore: remove one-off database scripts and a stale review note packages/db carried four hand-run scripts outside the package build that query or seed a developer database with a hard-coded connection string, one of them for a specific account. docs/VOICE-FIRST-REVIEW.md was a one-time verification note for a workflow that no longer exists and named private machines and local paths. Nothing references them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * chore: replace machine-specific paths and addresses with placeholders ORIGIN.md pinned the intended checkout to an absolute path under a personal home directory, docs/LOCAL-SETUP.md listed folders on one developer's machine, and two tests used what looks like a real tailnet address. Use a home-relative path, drop the machine-specific folder list, and use 100.100.100.100 in the tests; it is still inside 100.64.0.0/10, so both tests check the same thing as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test(web): drop the trading status from the shell contrast test main's contrast test now waits for the sidebar trading status before measuring; this branch removes that status. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
264 lines
11 KiB
TypeScript
264 lines
11 KiB
TypeScript
import { expect, type Locator, type Page, test } from "@playwright/test";
|
|
import {
|
|
captureScreenshot,
|
|
completeOnboarding,
|
|
createNamedBot,
|
|
openNewBot,
|
|
rpc,
|
|
signup,
|
|
waitForChatComposer,
|
|
} from "./helpers";
|
|
|
|
// WCAG AA for body text.
|
|
const MIN_CONTRAST = 4.5;
|
|
|
|
test.use({ viewport: { width: 1280, height: 1000 } });
|
|
|
|
/**
|
|
* Texts in `locator` below WCAG AA against the surface they are painted on. With `inside`, every
|
|
* visible text and typed value within it counts, except in disabled controls, which WCAG exempts,
|
|
* and in elements matching `skip`.
|
|
*/
|
|
function unreadableTexts(locator: Locator, inside: boolean, skip: string | null) {
|
|
return locator.evaluate(
|
|
(root, { inside, skip, min }) => {
|
|
const context = document
|
|
.createElement("canvas")
|
|
.getContext("2d", { willReadFrequently: true });
|
|
if (!context) throw new Error("No 2D canvas context");
|
|
// The canvas normalizes every CSS color syntax (oklab, color-mix, ...) to sRGB.
|
|
const rgba = (color: string) => {
|
|
context.clearRect(0, 0, 1, 1);
|
|
context.fillStyle = color;
|
|
context.fillRect(0, 0, 1, 1);
|
|
const [r = 0, g = 0, b = 0, a = 0] = context.getImageData(0, 0, 1, 1).data;
|
|
return [r / 255, g / 255, b / 255, a / 255] as const;
|
|
};
|
|
// One pixel of `color`, painted inside `from` and composited through every ancestor's
|
|
// background color and opacity onto the white canvas.
|
|
const paint = (color: readonly [number, number, number, number], from: Element) => {
|
|
let [r, g, b, a] = color;
|
|
for (let node: Element | null = from; node; node = node.parentElement) {
|
|
const style = getComputedStyle(node);
|
|
const [br, bg, bb, ba] = rgba(style.backgroundColor);
|
|
const alpha = a + ba * (1 - a);
|
|
if (alpha > 0) {
|
|
r = (r * a + br * ba * (1 - a)) / alpha;
|
|
g = (g * a + bg * ba * (1 - a)) / alpha;
|
|
b = (b * a + bb * ba * (1 - a)) / alpha;
|
|
}
|
|
a = alpha * Number(style.opacity);
|
|
}
|
|
return [r * a + 1 - a, g * a + 1 - a, b * a + 1 - a] as const;
|
|
};
|
|
const luminance = (rgb: readonly [number, number, number]) => {
|
|
const [r = 0, g = 0, b = 0] = rgb.map((channel) =>
|
|
channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4,
|
|
);
|
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
};
|
|
const contrast = (element: Element) => {
|
|
const text = luminance(paint(rgba(getComputedStyle(element).color), element));
|
|
const surface = luminance(paint([0, 0, 0, 0], element));
|
|
return (Math.max(text, surface) + 0.05) / (Math.min(text, surface) + 0.05);
|
|
};
|
|
// What the element itself shows: a typed value, or its own text nodes.
|
|
const ownText = (element: Element) => {
|
|
if (element instanceof HTMLTextAreaElement) return element.value;
|
|
if (element instanceof HTMLInputElement) {
|
|
return /^(text|search|url|email|tel|number|password)$/.test(element.type)
|
|
? element.value
|
|
: "";
|
|
}
|
|
return Array.from(element.childNodes, (node) =>
|
|
node.nodeType === Node.TEXT_NODE ? node.textContent : "",
|
|
)
|
|
.join("")
|
|
.trim();
|
|
};
|
|
const shown = (element: Element) => {
|
|
const { width, height } = element.getBoundingClientRect();
|
|
return width > 1 && height > 1 && element.checkVisibility();
|
|
};
|
|
const elements = inside
|
|
? [root, ...root.querySelectorAll("*")].filter(
|
|
(element) =>
|
|
ownText(element) &&
|
|
shown(element) &&
|
|
!element.closest(":disabled") &&
|
|
!(skip && element.closest(skip)),
|
|
)
|
|
: [root];
|
|
return elements.flatMap((element) => {
|
|
const ratio = contrast(element);
|
|
const text = ownText(element) || element.textContent?.trim() || "";
|
|
return ratio < min ? [`${ratio.toFixed(2)}:1 ${text.slice(0, 48)}`] : [];
|
|
});
|
|
},
|
|
{ inside, skip, min: MIN_CONTRAST },
|
|
);
|
|
}
|
|
|
|
async function expectReadable(
|
|
locator: Locator,
|
|
what: string,
|
|
{ inside = false, skip = null }: { inside?: boolean; skip?: string | null } = {},
|
|
) {
|
|
await expect(locator).toBeVisible();
|
|
await expect
|
|
.poll(() => unreadableTexts(locator, inside, skip), { message: `${what} is readable` })
|
|
.toEqual([]);
|
|
}
|
|
|
|
async function inEachTheme(page: Page, check: (theme: "light" | "dark") => Promise<void>) {
|
|
for (const theme of ["light", "dark"] as const) {
|
|
await page.emulateMedia({ colorScheme: theme });
|
|
await expect(page.locator("html")).toHaveAttribute("data-theme", theme);
|
|
await check(theme);
|
|
}
|
|
}
|
|
|
|
function runStatus(page: Page, groupId: string) {
|
|
return rpc<{ run?: { status: string } | null }>(page, "threads/get", { groupId }).then(
|
|
(snapshot) => snapshot.run?.status ?? "idle",
|
|
);
|
|
}
|
|
|
|
async function send(page: Page, groupId: string, text: string) {
|
|
await expect
|
|
.poll(() => runStatus(page, groupId), { timeout: 60_000 })
|
|
.not.toMatch(/^(queued|running)$/);
|
|
const composer = page.getByRole("combobox", { name: "Message" });
|
|
await composer.fill(text);
|
|
await composer.press("Enter");
|
|
}
|
|
|
|
test("conversation text stays readable in light and dark themes", async ({ page }, testInfo) => {
|
|
const stamp = Date.now();
|
|
await page.emulateMedia({ colorScheme: "light" });
|
|
await signup(page, `chat-contrast-${stamp}@rakazo.test`, "password12", "Contrast QA");
|
|
await completeOnboarding(page, undefined, { mainConversation: true });
|
|
const groupId = new URL(page.url()).pathname.split("/").at(-1)!;
|
|
await rpc(page, "bots/create", {
|
|
name: "Researcher",
|
|
title: "",
|
|
description: "",
|
|
instructions: "",
|
|
notifyOnFinish: true,
|
|
});
|
|
await page.reload();
|
|
await waitForChatComposer(page);
|
|
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
|
|
const transcript = page.getByTestId("transcript");
|
|
|
|
// The scripted bot echoes the request, so its reply renders this markdown.
|
|
await send(
|
|
page,
|
|
groupId,
|
|
"Summarize the **owl facts** in `notes/owls.md`\n\n## Owl notes\n\n> Owls turn their heads\n\n```\nowls = 3\n```",
|
|
);
|
|
const firstMessage = transcript
|
|
.getByTestId("message-user-bubble")
|
|
.filter({ hasText: "Summarize the **owl facts**" });
|
|
const markdown = transcript
|
|
.locator(".rk-chat-markdown")
|
|
.filter({ hasText: "done. i handled: Summarize the owl facts" });
|
|
const reply = markdown.locator("p").filter({ hasText: "done. i handled" });
|
|
await expect(reply).toBeVisible({ timeout: 60_000 });
|
|
|
|
await send(page, groupId, "delegate to the bot named Researcher: collect three owl facts");
|
|
const task = transcript.getByTestId("delegated-task-card");
|
|
await expect(task).toHaveAttribute("data-status", "done", { timeout: 60_000 });
|
|
await expect(
|
|
transcript.getByText("closing the task: collect three owl facts.").first(),
|
|
).toBeVisible({ timeout: 60_000 });
|
|
|
|
await send(page, groupId, "ask me which city to use");
|
|
await expect.poll(() => runStatus(page, groupId), { timeout: 60_000 }).toBe("waiting_input");
|
|
const question = transcript
|
|
.locator(".rk-chat-markdown p")
|
|
.filter({ hasText: /^Which city should I use\?$/ });
|
|
// threads/get can observe waiting_input before the realtime feed paints the ask card.
|
|
if ((await question.count()) === 0) {
|
|
await page.reload({ waitUntil: "domcontentloaded" });
|
|
await waitForChatComposer(page);
|
|
}
|
|
await expect(question).toBeVisible({ timeout: 15_000 });
|
|
const lastMessage = transcript
|
|
.getByTestId("message-user-bubble")
|
|
.filter({ hasText: "ask me which city to use" });
|
|
await task.locator("summary").click();
|
|
const brief = task.locator(".rk-chat-markdown p");
|
|
await expect(brief).toHaveText("collect three owl facts");
|
|
|
|
await inEachTheme(page, async (theme) => {
|
|
await firstMessage.scrollIntoViewIfNeeded();
|
|
await captureScreenshot(page, testInfo, `chat-contrast-${theme}-reply`);
|
|
await expectReadable(firstMessage, `${theme}: the user's own message`);
|
|
await expectReadable(reply, `${theme}: a bot reply`);
|
|
await expectReadable(reply.locator("strong"), `${theme}: bold text in a bot reply`);
|
|
await expectReadable(reply.locator("code"), `${theme}: inline code in a bot reply`);
|
|
await expectReadable(markdown.locator("h2"), `${theme}: a heading in a bot reply`);
|
|
await expectReadable(markdown.locator("blockquote p"), `${theme}: a quote in a bot reply`);
|
|
await expectReadable(markdown.locator("pre code"), `${theme}: a code block in a bot reply`);
|
|
|
|
await brief.scrollIntoViewIfNeeded();
|
|
await captureScreenshot(page, testInfo, `chat-contrast-${theme}-task`);
|
|
await expectReadable(brief, `${theme}: the delegated task brief`);
|
|
|
|
await question.scrollIntoViewIfNeeded();
|
|
await captureScreenshot(page, testInfo, `chat-contrast-${theme}-ask`);
|
|
await expectReadable(question, `${theme}: the ask card question`);
|
|
await expectReadable(lastMessage, `${theme}: the latest user message`);
|
|
});
|
|
});
|
|
|
|
test("shell surfaces stay readable in light and dark themes", async ({ page }, testInfo) => {
|
|
await page.emulateMedia({ colorScheme: "light" });
|
|
await signup(page, `shell-contrast-${Date.now()}@rakazo.test`, "password12", "Contrast QA");
|
|
await completeOnboarding(page);
|
|
await createNamedBot(page, "Researcher");
|
|
const composer = page.getByRole("combobox", { name: "Message" });
|
|
await composer.fill("Draft a note about owls");
|
|
const sidebar = page.getByTestId("bots-sidebar");
|
|
const sidePanel = page.getByTestId("side-panel");
|
|
await expect(sidebar.locator("[data-main-conversation]")).toBeVisible();
|
|
await expect(sidebar.getByRole("button", { name: "Researcher, 1:1 conversation" })).toBeVisible();
|
|
|
|
await inEachTheme(page, async (theme) => {
|
|
await captureScreenshot(page, testInfo, `shell-contrast-${theme}`);
|
|
await expectReadable(composer, `${theme}: the composer draft`);
|
|
await expectReadable(page.getByTestId("composer-bar"), `${theme}: the composer`, {
|
|
inside: true,
|
|
});
|
|
// The header carries the logo, which WCAG exempts; its create button is checked on its own.
|
|
await expectReadable(sidebar, `${theme}: the sidebar`, { inside: true, skip: ".app-drag" });
|
|
await expectReadable(page.getByTestId("create-menu-trigger"), `${theme}: the create button`);
|
|
});
|
|
|
|
await openNewBot(page);
|
|
await page.getByTestId("create-bot-form").locator("label:has-text('Name') input").fill("Owl");
|
|
await inEachTheme(page, async (theme) => {
|
|
await captureScreenshot(page, testInfo, `shell-contrast-${theme}-new-bot`);
|
|
await expectReadable(sidePanel, `${theme}: the new bot form`, { inside: true });
|
|
});
|
|
|
|
await page.getByRole("button", { name: "Cancel new bot" }).click();
|
|
await page.getByTestId("bot-settings-trigger").click();
|
|
const advanced = page.getByTestId("bot-settings-advanced");
|
|
await advanced.locator("summary").click();
|
|
await expect(advanced.getByText("Open work")).toBeVisible();
|
|
await inEachTheme(page, async (theme) => {
|
|
await captureScreenshot(page, testInfo, `shell-contrast-${theme}-settings`);
|
|
await expectReadable(sidePanel, `${theme}: the bot settings form`, { inside: true });
|
|
});
|
|
|
|
await sidebar.getByRole("button", { name: "Heute", exact: true }).click();
|
|
const heute = page.getByTestId("heute-brief");
|
|
await expect(heute.getByTestId("heute-mission-control-link")).toBeVisible();
|
|
await inEachTheme(page, async (theme) => {
|
|
await captureScreenshot(page, testInfo, `shell-contrast-${theme}-heute`);
|
|
await expectReadable(heute, `${theme}: the Heute brief`, { inside: true });
|
|
});
|
|
});
|