/** * Basic UI tests - adapted from old devtalk.e2e.spec.ts "basic UI" describe block. * * Changes from old plugin: * - No sidebar panel/toggle button (inline-only architecture) * - threads-app replaces devtalk-app as root element * - "New Thread" button replaces sidebar toggle * - Threads rendered server-side with .threads-* classes */ import { test, expect } from "@playwright/test"; import { Actor, NavigateTo } from "./screenplay.ts"; import { setAuthor, waitForClientProcessing } from "./helpers.ts"; let user: Actor; test.beforeEach(async ({ page }) => { user = new Actor("TestUser", page); await page.goto("/"); await page.waitForSelector("threads-app", { state: "attached", timeout: 10_000 }); await setAuthor(page, "TestUser"); // NOTE: Do NOT clean threads here - these tests verify the pre-existing // threads from the playground markdown (index.md ::: threads block) // Wait for client-side JS to finish processing highlights await waitForClientProcessing(page); }); test.describe("threads plugin - basic UI", () => { test("loads on the page with threads-app element", async () => { await user.perform(NavigateTo.path("/")); const app = user.page.locator("threads-app"); await expect(app).toBeAttached(); }); test("New Thread button is injected into the content area", async () => { await user.perform(NavigateTo.path("/")); const btn = user.page.locator(".threads-new-thread-btn").first(); await expect(btn).toBeVisible({ timeout: 5_000 }); await expect(btn).toContainText("New Thread"); }); test("server-rendered threads appear with correct class structure", async () => { // The playground index.md has pre-existing threads in the ::: threads block await user.perform(NavigateTo.path("/")); // There should be thread cards rendered from the markdown const threads = user.page.locator(".threads-thread[data-thread-id]"); const count = await threads.count(); expect(count).toBeGreaterThan(0); // Each thread should have at least one comment const firstThread = threads.first(); const comments = firstThread.locator(".threads-comment"); await expect(comments.first()).toBeAttached(); }); test("highlights appear on marked text", async () => { await user.perform(NavigateTo.path("/")); // The playground has ==highlighted text=={thread-id} in the markdown const highlights = user.page.locator("mark.threads-highlight[data-thread-id]"); const count = await highlights.count(); expect(count).toBeGreaterThan(0); }); test("highlights have cycling colors (not all the same)", async () => { await user.perform(NavigateTo.path("/")); await waitForClientProcessing(user.page); const highlights = user.page.locator("mark.threads-highlight[data-thread-id]"); const count = await highlights.count(); if (count < 2) { test.skip(true, "Need at least 2 highlights to test color cycling"); return; } // Collect all highlight color classes const colorClasses: string[] = []; for (let i = 0; i < count; i++) { const classList = await highlights.nth(i).evaluate((el) => Array.from(el.classList).filter((c) => c.startsWith("threads-hl-")), ); colorClasses.push(...classList); } // With 2+ highlights, we should see at least 2 different colors const uniqueColors = new Set(colorClasses); expect(uniqueColors.size).toBeGreaterThanOrEqual(2); }); test("thread cards are placed inline after their highlighted block", async () => { await user.perform(NavigateTo.path("/")); await waitForClientProcessing(user.page); // Find a highlight mark that has a corresponding thread card const highlights = user.page.locator("mark.threads-highlight[data-thread-id]"); const firstHighlight = highlights.first(); if ((await firstHighlight.count()) === 0) { test.skip(true, "No highlights found on the page"); return; } const threadId = await firstHighlight.getAttribute("data-thread-id"); if (!threadId) return; const threadCard = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`); await expect(threadCard).toBeAttached(); // Thread card should appear after the highlight's parent block in document order const isAfterHighlight = await user.page.evaluate( ({ threadId }) => { const mark = document.querySelector(`mark.threads-highlight[data-thread-id="${threadId}"]`); const thread = document.querySelector(`.threads-thread[data-thread-id="${threadId}"]`); if (!mark || !thread) return false; return mark.compareDocumentPosition(thread) & Node.DOCUMENT_POSITION_FOLLOWING; }, { threadId }, ); expect(isAfterHighlight).toBeTruthy(); }); test("thread cards have matching border colors to their highlights", async () => { await user.perform(NavigateTo.path("/")); await waitForClientProcessing(user.page); const highlights = user.page.locator("mark.threads-highlight[data-thread-id]"); const firstHighlight = highlights.first(); if ((await firstHighlight.count()) === 0) { test.skip(true, "No highlights found"); return; } const threadId = await firstHighlight.getAttribute("data-thread-id"); if (!threadId) return; // Get the highlight's color class const hlColorClass = await firstHighlight.evaluate((el) => Array.from(el.classList).find((c) => c.startsWith("threads-hl-")), ); if (!hlColorClass) return; // The thread card should have the matching border class const expectedBorderClass = hlColorClass.replace("threads-hl-", "threads-border-"); const threadCard = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`); const hasBorderClass = await threadCard.evaluate( (el, cls) => el.classList.contains(cls), expectedBorderClass, ); expect(hasBorderClass).toBe(true); }); test("clicking a highlight scrolls to and flashes its thread", async () => { await user.perform(NavigateTo.path("/")); await waitForClientProcessing(user.page); const highlights = user.page.locator("mark.threads-highlight[data-thread-id]"); const firstHighlight = highlights.first(); if ((await firstHighlight.count()) === 0) { test.skip(true, "No highlights found"); return; } await firstHighlight.click(); // After clicking, the thread card should receive the flash class const threadId = await firstHighlight.getAttribute("data-thread-id"); const threadCard = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`); await expect(threadCard).toHaveClass(/threads-thread--flash/, { timeout: 2_000 }); }); test("popover hidden initially without text selection", async () => { await user.perform(NavigateTo.path("/")); // Without any text selection, the popover should not show await expect( user.page.locator("threads-popover").getByText("Add comment"), ).not.toBeVisible(); }); test("reply buttons present on thread cards", async () => { await user.perform(NavigateTo.path("/")); const threads = user.page.locator(".threads-thread[data-thread-id]"); const count = await threads.count(); if (count === 0) { test.skip(true, "No threads on page"); return; } // Each thread should have a Reply button for (let i = 0; i < count; i++) { const thread = threads.nth(i); const replyBtn = thread.locator(".threads-comment-reply-btn").first(); await expect(replyBtn).toBeAttached(); await expect(replyBtn).toContainText("Reply"); } }); test("threads-sidebar container is hidden", async () => { await user.perform(NavigateTo.path("/")); const sidebar = user.page.locator(".threads-sidebar"); if ((await sidebar.count()) > 0) { // The sidebar wrapper should be hidden (display: none) await expect(sidebar).toBeHidden(); } }); });