Files
docmd-io--docmd/packages/plugins/threads/e2e/basic-ui.e2e.spec.ts
T
wehub-resource-sync 6db8fca185
docmd CI verification / verify (push) Failing after 0s
chore: import upstream snapshot with attribution
2026-07-13 12:31:55 +08:00

217 lines
7.8 KiB
TypeScript

/**
* 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();
}
});
});