chore: import upstream snapshot with attribution
docmd CI verification / verify (push) Failing after 0s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:31:55 +08:00
commit 6db8fca185
437 changed files with 68762 additions and 0 deletions
@@ -0,0 +1,216 @@
/**
* 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();
}
});
});
@@ -0,0 +1,142 @@
/**
* Comment display tests - adapted from old comment-display.e2e.spec.ts.
*
* Changes from old plugin:
* - Comments are server-rendered HTML (.threads-comment) not Lit components
* - Server-rendered comments have .threads-comment__meta and .threads-comment__body
* - Author info comes from data-author attribute on .threads-comment element
* - Date comes from data-date attribute
* - No "just now" time formatting (server renders static date strings like "2026-03-09")
*/
import { test, expect } from "@playwright/test";
import { Actor } from "./screenplay.ts";
import { cleanThreads, setAuthor, seedAndReload, waitForReload } 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");
await cleanThreads(page);
await waitForReload(page);
});
test.describe("comment display", () => {
test("author name is displayed on the comment", async () => {
const { id: threadId } = await seedAndReload(user.page, {
author: "Alice",
body: "Hello from Alice",
});
const thread = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
await expect(thread).toBeAttached({ timeout: 5_000 });
// Server-rendered comment should show the author
const meta = thread.locator(".threads-comment__meta").first();
await expect(meta).toContainText("Alice");
});
test("comment body is displayed", async () => {
const { id: threadId } = await seedAndReload(user.page, {
author: "TestUser",
body: "This is the comment body text",
});
const thread = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
await expect(thread).toBeAttached({ timeout: 5_000 });
const body = thread.locator(".threads-comment__body").first();
await expect(body).toContainText("This is the comment body text");
});
test("date is displayed on the comment", async () => {
const { id: threadId } = await seedAndReload(user.page, {
author: "TestUser",
body: "Date display test",
});
const thread = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
await expect(thread).toBeAttached({ timeout: 5_000 });
// Server-rendered meta should contain a date (format: YYYY-MM-DD)
const meta = thread.locator(".threads-comment__meta").first();
const metaText = await meta.textContent();
// Should contain a date pattern like 2026-03-09
expect(metaText).toMatch(/\d{4}-\d{2}-\d{2}/);
});
test("comment has data-author attribute", async () => {
const { id: threadId } = await seedAndReload(user.page, {
author: "Bob",
body: "Attribute test",
});
const thread = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
const comment = thread.locator(".threads-comment").first();
await expect(comment).toHaveAttribute("data-author", "Bob");
});
test("multiple comments displayed in order", async () => {
const { id: threadId } = await seedAndReload(user.page, {
author: "Alice",
body: "First comment",
replies: [
{ author: "Bob", body: "Second comment" },
{ author: "Charlie", body: "Third comment" },
],
});
let thread = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
await expect(thread).toBeAttached({ timeout: 5_000 });
// The dev server may need an additional reload to pick up the last reply
let comments = thread.locator(".threads-comment");
if ((await comments.count()) < 3) {
await waitForReload(user.page);
thread = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
comments = thread.locator(".threads-comment");
}
await expect(comments).toHaveCount(3);
await expect(comments.nth(0).locator(".threads-comment__body")).toContainText("First comment");
await expect(comments.nth(1).locator(".threads-comment__body")).toContainText("Second comment");
await expect(comments.nth(2).locator(".threads-comment__body")).toContainText("Third comment");
});
});
test.describe("markdown rendering in comments", () => {
test("comment body renders inline markdown from markdown-it", async () => {
// Server-rendered comments go through markdown-it, which handles bold/italic/code
const { id: threadId } = await seedAndReload(user.page, {
body: "Use `console.log()` for debugging",
});
const thread = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
const body = thread.locator(".threads-comment__body").first();
await expect(body).toBeAttached({ timeout: 5_000 });
// markdown-it should render backtick as <code>
const codeEl = body.locator("code");
await expect(codeEl).toBeAttached();
});
test("HTML special characters are escaped", async () => {
const { id: threadId } = await seedAndReload(user.page, {
body: "<script>alert('xss')</script>",
});
const thread = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
const body = thread.locator(".threads-comment__body").first();
await expect(body).toBeAttached({ timeout: 5_000 });
// No live <script> element must exist inside the comment body
await expect(body.locator("script")).toHaveCount(0);
// The raw innerHTML must contain escaped entities
const rawHtml = await body.evaluate((el) => el.innerHTML);
expect(rawHtml).not.toContain("<script>");
});
});
@@ -0,0 +1,76 @@
---
title: "docmd _playground"
description: "A testing ground for docmd core engine features."
---
This playground is used to verify core engine changes in real-time. Use this to ensure your modifications to the Markdown parser or UI components behave as expected.
::: callout tip "How to work here"
To test your changes, keep the dev server running (`pnpm run dev`). Any change you make to `packages/core` or `packages/parser` will trigger a hot-reload here instantly.
:::
## Component Verification
Test your UI components and parser rules here to ensure visual consistency:
::: card "Container Test"
Test nested callouts and containers.
::: callout warning "Warning"
Ensure nested items render correctly.
:::
:::
::: tabs
== tab "Feature A"
### Feature A
Verification content.
== tab "Feature B"
### Feature B
Verification content.
:::
## 🔗 Useful Links
- [Official Documentation](https://docs.docmd.io)
- [GitHub Repository](https://github.com/docmd-io/docmd)
- [Report an Issue](https://github.com/docmd-io/docmd/issues)
## 🧪 Developer Checklist
- [ ] **Parser:** Does the Markdown output match the HTML in `packages/parser/src/html-renderer.js`?
- [ ] **UI:** Does the theme CSS apply to this page correctly?
- [ ] **SPA:** Does navigation between pages work without a hard refresh?
## Threads Plugin Test
This section tests the ==inline discussion threads=={t-thread1} plugin. You can ==highlight text=={t-thread2} and start discussions.
Here is another paragraph with a ==different highlight=={t-thread3} to test multiple threads.
::: threads
::: thread t-thread1
::: comment c1-1 "Alice" "2026-03-01"
This is a comment about inline discussion threads.
:::
::: comment c1-2 "Bob" "2026-03-02"
Great point, Alice! I agree this is useful.
:::
:::
::: thread t-thread2
::: comment c2-1 "Charlie" "2026-03-03"
Highlighting text makes it easy to reference specific parts.
:::
:::
::: thread t-thread3 resolved "Alice" "2026-03-05"
::: comment c3-1 "Alice" "2026-03-04"
This highlight tests resolved threads.
:::
::: comment c3-2 "Bob" "2026-03-05"
Resolved! Use `console.log()` for debugging.
:::
:::
:::
+8
View File
@@ -0,0 +1,8 @@
/**
* Global type declarations for the docmd browser runtime,
* used in Playwright page.evaluate() contexts.
*/
declare const docmd: {
call: (action: string, payload: Record<string, unknown>) => Promise<any>;
};
+215
View File
@@ -0,0 +1,215 @@
/**
* Helpers for seeding and cleaning threads in E2E tests.
*
* Unlike the old HTTP API-based approach, we use docmd.call() via
* page.evaluate() to interact with the WebSocket action dispatcher.
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import type { Page } from "@playwright/test";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/** Path to the playground index.md used by the dev server. */
const PLAYGROUND_INDEX = path.resolve(
__dirname,
"../../../_playground/docs/index.md",
);
/** Path to the fixture copy of the original playground markdown. */
const FIXTURE_INDEX = path.resolve(__dirname, "fixtures/playground-index.md");
/**
* Restore the playground markdown to its fixture state, removing any
* dynamically-added threads/highlights from test runs.
*/
export async function cleanThreads(_page: Page): Promise<void> {
const fixtureContent = fs.readFileSync(FIXTURE_INDEX, "utf-8");
fs.writeFileSync(PLAYGROUND_INDEX, fixtureContent, "utf-8");
}
/**
* Seed a thread via the WebSocket action dispatcher.
* Must be called after the page is loaded and docmd global is available.
*
* Each WebSocket call writes to the markdown file, which may trigger the
* dev server's file watcher to hot-reload the page. To avoid execution
* context destruction, we split multi-step operations (add-thread +
* add-comment replies) across separate page.evaluate() calls with page
* reloads in between.
*/
export async function seedThread(
page: Page,
options: {
author?: string;
body?: string;
anchor?: { quote: string } | null;
replies?: Array<{ author?: string; body: string }>;
resolved?: boolean;
} = {},
): Promise<{ id: string; comments: Array<{ id: string }> }> {
const {
author = "TestUser",
body = "Seeded comment",
anchor = null,
replies = [],
resolved = false,
} = options;
// Step 1: Create the thread with its first comment
const thread = await page.evaluate(
async ({ author, body, anchor }) => {
const file = document.body.dataset["sourceFile"];
if (!file) throw new Error("data-source-file not found on body");
return docmd.call("threads:add-thread", {
file,
author,
body,
anchor: anchor
? {
quote: anchor.quote,
prefix: null,
suffix: null,
selector: null,
offset: null,
blockText: null,
}
: null,
});
},
{ author, body, anchor },
);
const commentIds = [thread.comments[0].id];
// Step 2: Add replies one at a time, reloading the page between each
// to avoid execution context destruction from hot-reload
for (const reply of replies) {
await page.waitForTimeout(500);
await page.reload({ waitUntil: "domcontentloaded" });
await page.waitForSelector("threads-app", { state: "attached", timeout: 10_000 });
const comment = await page.evaluate(
async ({ file, threadId, replyAuthor, replyBody }) => {
const f = file || document.body.dataset["sourceFile"];
return docmd.call("threads:add-comment", {
file: f,
threadId,
author: replyAuthor,
body: replyBody,
});
},
{
file: null as string | null,
threadId: thread.id,
replyAuthor: reply.author ?? "TestUser",
replyBody: reply.body,
},
);
commentIds.push(comment.id);
}
// Step 3: Resolve if requested
if (resolved) {
// Always reload before resolving to ensure the DOM is fresh after the last write
// and avoid execution context destruction during the resolve-thread call.
await page.waitForTimeout(500);
await page.reload({ waitUntil: "domcontentloaded" });
await page.waitForSelector("threads-app", { state: "attached", timeout: 10_000 });
await page.evaluate(
async ({ threadId, resolvedBy }) => {
const file = document.body.dataset["sourceFile"];
return docmd.call("threads:resolve-thread", {
file,
threadId,
resolved_by: resolvedBy,
});
},
{ threadId: thread.id, resolvedBy: author },
);
}
return {
id: thread.id,
comments: commentIds.map((id) => ({ id })),
};
}
/**
* Reload the page and wait for the threads-app element to re-attach.
* After seeding/cleaning threads via WebSocket actions, the markdown file
* on disk has changed. Give the dev server a moment to detect the change,
* then reload.
*/
export async function waitForReload(page: Page): Promise<void> {
await page.waitForTimeout(2000);
await page.reload({ waitUntil: "domcontentloaded" });
await page.waitForSelector("threads-app", { state: "attached", timeout: 10_000 });
}
/**
* Seed a thread, reload the page, and wait until the thread element appears
* in the server-rendered HTML.
*/
export async function seedAndReload(
page: Page,
options: Parameters<typeof seedThread>[1] = {},
): Promise<{ id: string; comments: Array<{ id: string }> }> {
const result = await seedThread(page, options);
// The seed action writes to the markdown file. The dev server needs time
// to detect the change and re-render. Retry the reload until the thread
// appears in the server-rendered HTML (up to 3 attempts).
const selector = `.threads-thread[data-thread-id="${result.id}"]`;
for (let attempt = 0; attempt < 3; attempt++) {
await page.waitForTimeout(attempt === 0 ? 500 : 1500);
await page.goto(page.url(), { waitUntil: "domcontentloaded" });
await page.waitForSelector("threads-app", { state: "attached", timeout: 10_000 });
const found = await page.locator(selector).count();
if (found > 0) return result;
}
// Final attempt - let Playwright's own timeout handle it
await page.waitForSelector(selector, { state: "attached", timeout: 5_000 });
return result;
}
/**
* Set the author identity in localStorage.
*/
export async function setAuthor(page: Page, name: string): Promise<void> {
await page.evaluate(
(authorName) => localStorage.setItem("threads_author", authorName),
name,
);
}
/**
* Wait for the client-side threads-app to finish processing:
* scanRenderedHighlights assigns color classes, moves thread cards inline, etc.
* We detect completion by waiting for the first highlight to receive a color class.
*/
export async function waitForClientProcessing(page: Page): Promise<void> {
const hasHighlights = await page.locator("mark.threads-highlight[data-thread-id]").count();
if (hasHighlights > 0) {
// Wait for the first highlight to get a color class (assigned by scanRenderedHighlights)
await page.waitForFunction(
() => {
const mark = document.querySelector("mark.threads-highlight[data-thread-id]");
if (!mark) return true; // No highlights, nothing to wait for
return Array.from(mark.classList).some((c) => c.startsWith("threads-hl-"));
},
{ timeout: 5_000 },
);
} else {
// No highlights on page, just wait a moment for client JS to initialize
await page.waitForTimeout(500);
}
}
@@ -0,0 +1,266 @@
/**
* Inline editor tests - adapted from old inline-editor.e2e.spec.ts.
*
* Changes from old plugin:
* - threads-inline-editor replaces devtalk-inline-editor
* - "Add comment" popover replaces "Add to page" button
* - No separate panel compose form; all editing is inline
*/
import { test, expect } from "@playwright/test";
import {
Actor,
NavigateTo,
SelectText,
SubmitWithKeyboard,
CancelWithKeyboard,
TheInlineEditor,
} from "./screenplay.ts";
import { cleanThreads, setAuthor, waitForReload } from "./helpers.ts";
const contentSelector = "[data-docmd-content] p, .docmd-content p, article p, main p";
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");
await cleanThreads(page);
await waitForReload(page);
});
/** Helper: get text to select from the first paragraph */
async function getTextToSelect(): Promise<string> {
await user.page.waitForSelector(contentSelector, { timeout: 5_000 });
const firstParagraph = user.page.locator(contentSelector).first();
const paragraphText = await firstParagraph.textContent();
if (!paragraphText || paragraphText.trim().length < 5) {
throw new Error("No suitable text content found to select");
}
return paragraphText.trim().slice(0, 20);
}
/** Helper: open inline editor by selecting text and clicking "Add comment" */
async function openInlineEditor(textToSelect: string): Promise<void> {
await user.perform(SelectText.within(contentSelector, textToSelect));
const addCommentBtn = user.page.locator("threads-popover").getByText("Add comment");
await addCommentBtn.waitFor({ state: "visible", timeout: 5_000 });
await addCommentBtn.click();
const editor = user.page.locator("threads-inline-editor");
await editor.waitFor({ state: "attached", timeout: 5_000 });
}
/** Helper: type text into the inline editor's textarea */
async function typeInEditor(text: string): Promise<void> {
const textarea = user.page
.locator("threads-inline-editor")
.locator("wa-textarea")
.locator("textarea");
await textarea.fill(text);
}
// ---------------------------------------------------------------------------
// Inline editor basics
// ---------------------------------------------------------------------------
test.describe("inline editor basics", () => {
test("shows inline editor after clicking Add comment", async () => {
await user.perform(NavigateTo.path("/"));
const textToSelect = await getTextToSelect();
await openInlineEditor(textToSelect);
expect(await user.ask(TheInlineEditor.isVisible())).toBe(true);
});
test("submits inline comment and editor disappears", async () => {
await user.perform(NavigateTo.path("/"));
const textToSelect = await getTextToSelect();
await openInlineEditor(textToSelect);
await typeInEditor("Inline comment via selection");
const editor = user.page.locator("threads-inline-editor");
const submitBtn = editor.locator("wa-button[variant='brand']");
await submitBtn.click();
await editor.waitFor({ state: "detached", timeout: 10_000 });
});
test("cancel removes inline editor", async () => {
await user.perform(NavigateTo.path("/"));
const textToSelect = await getTextToSelect();
await openInlineEditor(textToSelect);
expect(await user.ask(TheInlineEditor.isVisible())).toBe(true);
const editor = user.page.locator("threads-inline-editor");
const cancelBtn = editor.locator("wa-button[appearance='outlined']");
await cancelBtn.click();
await editor.waitFor({ state: "detached", timeout: 5_000 });
});
test("submit button is disabled when textarea is empty", async () => {
await user.perform(NavigateTo.path("/"));
const textToSelect = await getTextToSelect();
await openInlineEditor(textToSelect);
const editor = user.page.locator("threads-inline-editor");
const submitBtn = editor.locator("wa-button[variant='brand']");
await expect(submitBtn).toHaveAttribute("disabled", { timeout: 3_000 });
});
});
// ---------------------------------------------------------------------------
// Keyboard shortcuts
// ---------------------------------------------------------------------------
test.describe("keyboard shortcuts", () => {
test("Cmd+Enter submits inline editor", async () => {
await user.perform(NavigateTo.path("/"));
const textToSelect = await getTextToSelect();
await openInlineEditor(textToSelect);
await typeInEditor("Submitted via keyboard");
await user.perform(SubmitWithKeyboard.inInlineEditor());
const editor = user.page.locator("threads-inline-editor");
await editor.waitFor({ state: "detached", timeout: 10_000 });
});
test("Escape cancels inline editor", async () => {
await user.perform(NavigateTo.path("/"));
const textToSelect = await getTextToSelect();
await openInlineEditor(textToSelect);
await user.perform(CancelWithKeyboard.inInlineEditor());
const editor = user.page.locator("threads-inline-editor");
await editor.waitFor({ state: "detached", timeout: 5_000 });
});
test("Cmd+Enter does nothing when textarea is empty", async () => {
await user.perform(NavigateTo.path("/"));
const textToSelect = await getTextToSelect();
await openInlineEditor(textToSelect);
// Press Cmd+Enter without typing anything
await user.perform(SubmitWithKeyboard.inInlineEditor());
// Editor should still be present
const editor = user.page.locator("threads-inline-editor");
await expect(editor).toBeAttached({ timeout: 1_000 });
});
});
// ---------------------------------------------------------------------------
// Inline editor validation
// ---------------------------------------------------------------------------
test.describe("inline editor validation", () => {
test("whitespace-only input keeps submit button disabled", async () => {
await user.perform(NavigateTo.path("/"));
const textToSelect = await getTextToSelect();
await openInlineEditor(textToSelect);
const textarea = user.page
.locator("threads-inline-editor")
.locator("wa-textarea")
.locator("textarea");
await textarea.fill(" ");
const submitBtn = user.page
.locator("threads-inline-editor")
.locator("wa-button[variant='brand']");
await expect(submitBtn).toHaveAttribute("disabled");
});
test("inline editor textarea gets autofocus on open", async () => {
await user.perform(NavigateTo.path("/"));
const textToSelect = await getTextToSelect();
await openInlineEditor(textToSelect);
const textarea = user.page
.locator("threads-inline-editor")
.locator("wa-textarea")
.locator("textarea");
await expect(textarea).toBeFocused({ timeout: 3_000 });
});
});
// ---------------------------------------------------------------------------
// Inline editor submitting state
// ---------------------------------------------------------------------------
test.describe("inline editor submitting state", () => {
test("submit button shows 'Submit' text in default state", async () => {
await user.perform(NavigateTo.path("/"));
const textToSelect = await getTextToSelect();
await openInlineEditor(textToSelect);
await typeInEditor("Some comment text");
const editor = user.page.locator("threads-inline-editor");
const submitBtn = editor.locator("wa-button[variant='brand']");
await expect(submitBtn).toContainText("Submit");
});
test("submit button footer hint reads Cmd+Enter to submit", async () => {
await user.perform(NavigateTo.path("/"));
const textToSelect = await getTextToSelect();
await openInlineEditor(textToSelect);
const editor = user.page.locator("threads-inline-editor");
const hint = editor.locator(".hint");
await expect(hint).toBeVisible();
await expect(hint).toContainText("Cmd+Enter to submit");
});
test("double-submit prevention: clicking submit twice only submits once", async () => {
await user.perform(NavigateTo.path("/"));
const textToSelect = await getTextToSelect();
await openInlineEditor(textToSelect);
await typeInEditor("Double-submit test comment");
const editor = user.page.locator("threads-inline-editor");
const submitBtn = editor.locator("wa-button[variant='brand']");
await submitBtn.click();
await submitBtn.click({ force: true }).catch(() => {});
await editor.waitFor({ state: "detached", timeout: 10_000 });
});
});
// ---------------------------------------------------------------------------
// Inline editor placeholder text
// ---------------------------------------------------------------------------
test.describe("inline editor placeholder text", () => {
test("inline editor textarea has correct placeholder", async () => {
await user.perform(NavigateTo.path("/"));
await openInlineEditor(await getTextToSelect());
const editor = user.page.locator("threads-inline-editor");
const textarea = editor.locator("wa-textarea").locator("textarea");
await expect(textarea).toHaveAttribute("placeholder", "Write your comment...");
});
});
@@ -0,0 +1,49 @@
/**
* SPA navigation tests - adapted from old headings-navigation.e2e.spec.ts
* "SPA navigation" block.
*
* Changes from old plugin:
* - No sidebar panel
* - No heading discuss buttons
* - Threads are inline, not in a sidebar
* - Navigation restores threads by triggering loadThreads + scanRenderedHighlights
*/
import { test, expect } from "@playwright/test";
import { Actor, NavigateTo } from "./screenplay.ts";
import { setAuthor, seedAndReload } 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");
});
test.describe("SPA navigation", () => {
test("threads-app is present after page load", async () => {
await user.perform(NavigateTo.path("/"));
const app = user.page.locator("threads-app");
await expect(app).toBeAttached();
});
test("New Thread button is reinjected after SPA navigation", async () => {
await user.perform(NavigateTo.path("/"));
const btn = user.page.locator(".threads-new-thread-btn").first();
await expect(btn).toBeVisible({ timeout: 5_000 });
});
test("threads are page-scoped (pre-existing threads from markdown)", async () => {
// The playground index.md has threads defined in markdown
await user.perform(NavigateTo.path("/"));
const threadsOnIndex = await user.page.locator(".threads-thread[data-thread-id]").count();
// The index page has pre-defined threads
expect(threadsOnIndex).toBeGreaterThan(0);
});
});
@@ -0,0 +1,273 @@
/**
* Emoji reaction tests - adapted from old reactions.e2e.spec.ts.
*
* Changes from old plugin:
* - Reactions stored in markdown (not SQLite)
* - Server-rendered reactions in .threads-reactions block
* - Client-side reactions via threads-comment Lit component (tc-* classes)
* - Toggle via docmd.call('threads:toggle-reaction')
*
* NOTE: Each docmd.call() that modifies the markdown file triggers a hot-reload
* via the dev server's file watcher. We must waitForReload() between calls to
* avoid "execution context destroyed" errors.
*/
import { test, expect } from "@playwright/test";
import { Actor } from "./screenplay.ts";
import { cleanThreads, setAuthor, seedAndReload, waitForReload } 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");
await cleanThreads(page);
await waitForReload(page);
});
test.describe("emoji reactions API", () => {
test("adding a reaction persists it", async () => {
const { id: threadId, comments } = await seedAndReload(user.page, {
body: "Comment for reaction test",
});
const commentId = comments[0].id;
// Toggle a reaction via API
const reactions = await user.page.evaluate(
async ({ threadId, commentId }) => {
const file = document.body.dataset["sourceFile"];
return docmd.call("threads:toggle-reaction", {
file,
threadId,
commentId,
emoji: "\ud83d\udc4d",
author: "TestUser",
});
},
{ threadId, commentId },
);
expect(reactions).toBeTruthy();
expect(reactions.length).toBe(1);
expect(reactions[0].emoji).toBe("\ud83d\udc4d");
expect(reactions[0].authors).toContain("TestUser");
});
test("toggling same reaction removes it", async () => {
const { id: threadId, comments } = await seedAndReload(user.page, {
body: "Comment for toggle test",
});
const commentId = comments[0].id;
// Add reaction
await user.page.evaluate(
async ({ threadId, commentId }) => {
const file = document.body.dataset["sourceFile"];
await docmd.call("threads:toggle-reaction", {
file,
threadId,
commentId,
emoji: "\ud83d\udc4d",
author: "TestUser",
});
},
{ threadId, commentId },
);
// Wait for hot-reload after file write
await waitForReload(user.page);
// Toggle it off
const reactions = await user.page.evaluate(
async ({ threadId, commentId }) => {
const file = document.body.dataset["sourceFile"];
return docmd.call("threads:toggle-reaction", {
file,
threadId,
commentId,
emoji: "\ud83d\udc4d",
author: "TestUser",
});
},
{ threadId, commentId },
);
// Reaction should be removed
const thumbsReaction = reactions.find((r: any) => r.emoji === "\ud83d\udc4d");
expect(thumbsReaction).toBeFalsy();
});
test("multiple reactions on same comment", async () => {
const { id: threadId, comments } = await seedAndReload(user.page, {
body: "Comment for multi-reaction test",
});
const commentId = comments[0].id;
// Add thumbs up
await user.page.evaluate(
async ({ threadId, commentId }) => {
const file = document.body.dataset["sourceFile"];
await docmd.call("threads:toggle-reaction", {
file,
threadId,
commentId,
emoji: "\ud83d\udc4d",
author: "TestUser",
});
},
{ threadId, commentId },
);
// Wait for hot-reload
await waitForReload(user.page);
// Add party
const reactions = await user.page.evaluate(
async ({ threadId, commentId }) => {
const file = document.body.dataset["sourceFile"];
return docmd.call("threads:toggle-reaction", {
file,
threadId,
commentId,
emoji: "\ud83c\udf89",
author: "TestUser",
});
},
{ threadId, commentId },
);
expect(reactions.length).toBe(2);
expect(reactions.find((r: any) => r.emoji === "\ud83d\udc4d")).toBeTruthy();
expect(reactions.find((r: any) => r.emoji === "\ud83c\udf89")).toBeTruthy();
});
test("reactions persist after re-fetching threads", async () => {
const { id: threadId, comments } = await seedAndReload(user.page, {
body: "Comment for persistence test",
});
const commentId = comments[0].id;
// Add reaction
await user.page.evaluate(
async ({ threadId, commentId }) => {
const file = document.body.dataset["sourceFile"];
await docmd.call("threads:toggle-reaction", {
file,
threadId,
commentId,
emoji: "\ud83d\udc4d",
author: "TestUser",
});
},
{ threadId, commentId },
);
// Wait for hot-reload
await waitForReload(user.page);
// Re-fetch threads
const threads = await user.page.evaluate(async () => {
const file = document.body.dataset["sourceFile"];
return docmd.call("threads:get-threads", { file });
});
const thread = threads.find((t: any) => t.id === threadId);
expect(thread).toBeTruthy();
const comment = thread.comments[0];
expect(comment.reactions).toBeTruthy();
expect(comment.reactions.length).toBe(1);
expect(comment.reactions[0].emoji).toBe("\ud83d\udc4d");
expect(comment.reactions[0].authors).toContain("TestUser");
});
test("multiple authors on same reaction", async () => {
const { id: threadId, comments } = await seedAndReload(user.page, {
body: "Comment for multi-author test",
});
const commentId = comments[0].id;
// TestUser adds thumbs up
await user.page.evaluate(
async ({ threadId, commentId }) => {
const file = document.body.dataset["sourceFile"];
await docmd.call("threads:toggle-reaction", {
file,
threadId,
commentId,
emoji: "\ud83d\udc4d",
author: "TestUser",
});
},
{ threadId, commentId },
);
// Wait for hot-reload
await waitForReload(user.page);
// Alice adds thumbs up
const reactions = await user.page.evaluate(
async ({ threadId, commentId }) => {
const file = document.body.dataset["sourceFile"];
return docmd.call("threads:toggle-reaction", {
file,
threadId,
commentId,
emoji: "\ud83d\udc4d",
author: "Alice",
});
},
{ threadId, commentId },
);
const thumbs = reactions.find((r: any) => r.emoji === "\ud83d\udc4d");
expect(thumbs).toBeTruthy();
expect(thumbs.authors).toContain("TestUser");
expect(thumbs.authors).toContain("Alice");
expect(thumbs.authors.length).toBe(2);
});
});
test.describe("server-rendered reactions", () => {
test("reactions block rendered in server HTML when reactions exist", async () => {
// Seed a thread with a reaction, then reload to see server rendering
const { id: threadId, comments } = await seedAndReload(user.page, {
body: "Comment with reaction",
});
const commentId = comments[0].id;
// Add reaction
await user.page.evaluate(
async ({ threadId, commentId }) => {
const file = document.body.dataset["sourceFile"];
await docmd.call("threads:toggle-reaction", {
file,
threadId,
commentId,
emoji: "\ud83d\udc4d",
author: "TestUser",
});
},
{ threadId, commentId },
);
// Reload to see server-rendered HTML
await waitForReload(user.page);
const thread = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
const reactionsBlock = thread.locator(".threads-reactions");
// If reactions are rendered server-side, the block should be present
if ((await reactionsBlock.count()) > 0) {
await expect(reactionsBlock).toContainText("\ud83d\udc4d");
}
});
});
@@ -0,0 +1,256 @@
/**
* Reply and deletion tests - adapted from old editing-deletion.e2e.spec.ts
* and devtalk.e2e.spec.ts "discussions" block.
*
* Changes from old plugin:
* - No sidebar panel; threads are inline server-rendered cards
* - Reply button on thread footer (not an always-visible reply textarea)
* - Delete confirmation via wa-dialog#delete-dialog (same as before)
* - Seeding via docmd.call() instead of HTTP API
* - Comment editing now handled by threads-comment Lit component with tc-* classes
*/
import { test, expect } from "@playwright/test";
import {
Actor,
ReplyToThread,
} from "./screenplay.ts";
import { cleanThreads, setAuthor, seedAndReload, waitForReload, 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");
await cleanThreads(page);
await waitForReload(page);
});
// ---------------------------------------------------------------------------
// Reply to thread
// ---------------------------------------------------------------------------
test.describe("reply to thread", () => {
test("clicking Reply opens inline editor inside thread card", async () => {
// Use an anchored thread so it gets moved inline (unanchored threads
// stay in the hidden sidebar and the Reply button is not visible)
const { id: threadId } = await seedAndReload(user.page, {
body: "Thread for reply test",
anchor: { quote: "verify core engine" },
});
await waitForClientProcessing(user.page);
const threadEl = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
await expect(threadEl).toBeVisible({ timeout: 5_000 });
const replyBtn = threadEl.locator(".threads-comment-reply-btn").first();
await expect(replyBtn).toBeVisible({ timeout: 5_000 });
await replyBtn.click();
const editor = user.page.locator("threads-inline-editor");
await expect(editor).toBeAttached({ timeout: 5_000 });
});
test("submitting reply adds a comment to the thread", async () => {
// Use anchored thread so it's visible inline
const { id: threadId } = await seedAndReload(user.page, {
body: "Thread for reply submission",
anchor: { quote: "verify core engine" },
});
await waitForClientProcessing(user.page);
const threadSelector = `.threads-thread[data-thread-id="${threadId}"]`;
await user.perform(
ReplyToThread.on(threadSelector, "I agree with this"),
);
// Wait for reload after the reply is submitted
await waitForReload(user.page);
// Verify reply was persisted via API
const threads = await user.page.evaluate(async () => {
const file = document.body.dataset["sourceFile"];
return docmd.call("threads:get-threads", { file });
});
const thread = threads.find((t: any) => t.id === threadId);
expect(thread).toBeTruthy();
expect(thread.comments.length).toBe(2);
expect(thread.comments[1].body).toBe("I agree with this");
});
});
// ---------------------------------------------------------------------------
// Thread deletion
// ---------------------------------------------------------------------------
test.describe("thread deletion", () => {
test("delete dialog opens when delete is requested", async () => {
const { id: threadId } = await seedAndReload(user.page, {
body: "Thread to delete",
});
const threadEl = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
await expect(threadEl).toBeAttached({ timeout: 5_000 });
// The threads-app component handles delete via event delegation
// We need to trigger the delete event on the thread card
// Since server-rendered threads don't have a built-in delete button,
// check if there's a mechanism. The threads-app handles delete-dialog.
// Let's check: the old plugin had a Delete button in the thread footer.
// In the new plugin, there's no delete button on server-rendered cards
// (only the Lit-rendered panel threads had Resolve/Delete).
// However, the delete functionality exists via the delete-dialog.
// For now, verify the delete dialog mechanism works:
const dialog = user.page.locator("wa-dialog#delete-dialog");
await expect(dialog).toBeAttached();
});
test("confirm delete removes thread via API", async () => {
const { id: threadId } = await seedAndReload(user.page, {
body: "Thread to delete via API",
});
// Delete via WebSocket API
await user.page.evaluate(
async ({ threadId }) => {
const file = document.body.dataset["sourceFile"];
await docmd.call("threads:delete-thread", { file, threadId });
},
{ threadId },
);
// Wait for hot-reload triggered by file write
await waitForReload(user.page);
// Verify deletion
const threads = await user.page.evaluate(async () => {
const file = document.body.dataset["sourceFile"];
return docmd.call("threads:get-threads", { file });
});
const deleted = threads.find((t: any) => t.id === threadId);
expect(deleted).toBeFalsy();
});
});
// ---------------------------------------------------------------------------
// Comment deletion
// ---------------------------------------------------------------------------
test.describe("comment deletion", () => {
test("deleting a reply leaves the root comment intact", async () => {
const { id: threadId, comments } = await seedAndReload(user.page, {
body: "Root comment",
replies: [{ body: "Reply to remove", author: "TestUser" }],
});
const replyId = comments[1].id;
// Delete via API
await user.page.evaluate(
async ({ threadId, commentId }) => {
const file = document.body.dataset["sourceFile"];
await docmd.call("threads:delete-comment", { file, threadId, commentId });
},
{ threadId, commentId: replyId },
);
// Wait for hot-reload triggered by file write
await waitForReload(user.page);
// Verify only root comment remains
const threads = await user.page.evaluate(async () => {
const file = document.body.dataset["sourceFile"];
return docmd.call("threads:get-threads", { file });
});
const thread = threads.find((t: any) => t.id === threadId);
expect(thread).toBeTruthy();
expect(thread.comments.length).toBe(1);
expect(thread.comments[0].body).toBe("Root comment");
});
});
// ---------------------------------------------------------------------------
// Comment editing
// ---------------------------------------------------------------------------
test.describe("comment editing", () => {
test("editing a comment persists the change", async () => {
const { id: threadId, comments } = await seedAndReload(user.page, {
body: "Original body for edit test",
});
const commentId = comments[0].id;
const newBody = "Edited comment body";
// Edit via API
await user.page.evaluate(
async ({ threadId, commentId, newBody }) => {
const file = document.body.dataset["sourceFile"];
await docmd.call("threads:edit-comment", {
file,
threadId,
commentId,
body: newBody,
});
},
{ threadId, commentId, newBody },
);
// Wait for hot-reload triggered by file write
await waitForReload(user.page);
// Verify edit persisted
const threads = await user.page.evaluate(async () => {
const file = document.body.dataset["sourceFile"];
return docmd.call("threads:get-threads", { file });
});
const thread = threads.find((t: any) => t.id === threadId);
expect(thread.comments[0].body).toBe(newBody);
expect(thread.comments[0].edited_at).toBeTruthy();
});
test("editing with empty body does nothing (guard)", async () => {
const { id: threadId, comments } = await seedAndReload(user.page, {
body: "Original content",
});
const commentId = comments[0].id;
// Attempt to edit with empty body - the API should reject it or the guard should prevent it
try {
await user.page.evaluate(
async ({ threadId, commentId }) => {
const file = document.body.dataset["sourceFile"];
await docmd.call("threads:edit-comment", {
file,
threadId,
commentId,
body: "",
});
},
{ threadId, commentId },
);
} catch {
// Expected - empty body should be rejected
}
// Verify original body unchanged
const threads = await user.page.evaluate(async () => {
const file = document.body.dataset["sourceFile"];
return docmd.call("threads:get-threads", { file });
});
const thread = threads.find((t: any) => t.id === threadId);
expect(thread.comments[0].body).toBe("Original content");
});
});
@@ -0,0 +1,104 @@
/**
* Thread resolve/unresolve tests - adapted from old devtalk.e2e.spec.ts
* "thread behavior" block.
*
* Changes from old plugin:
* - No sidebar panel with visual resolve/unresolve buttons on server-rendered cards
* - Resolve functionality exists at API level (threads:resolve-thread)
* - Resolved threads get .threads-thread--resolved class (opacity: 0.55)
* - The Lit-rendered threads-thread component has Resolve/Unresolve buttons,
* but the current inline mode uses server-rendered HTML
*/
import { test, expect } from "@playwright/test";
import { Actor } from "./screenplay.ts";
import { cleanThreads, setAuthor, seedAndReload, waitForReload } 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");
await cleanThreads(page);
await waitForReload(page);
});
test.describe("resolve thread API", () => {
test("resolving a thread sets resolved state", async () => {
const { id: threadId } = await seedAndReload(user.page, {
body: "Thread to resolve",
});
// Resolve via API
const resolved = await user.page.evaluate(
async ({ threadId }) => {
const file = document.body.dataset["sourceFile"];
return docmd.call("threads:resolve-thread", {
file,
threadId,
resolved_by: "TestUser",
});
},
{ threadId },
);
expect(resolved.resolved).toBeTruthy();
// The resolve call wrote to the file, triggering hot-reload. Wait for it.
await waitForReload(user.page);
// Verify via re-fetch
const threads = await user.page.evaluate(async () => {
const file = document.body.dataset["sourceFile"];
return docmd.call("threads:get-threads", { file });
});
const thread = threads.find((t: any) => t.id === threadId);
expect(thread.resolved).toBeTruthy();
});
test("unresolving a thread toggles back to open", async () => {
// Seed a resolved thread
const { id: threadId } = await seedAndReload(user.page, {
body: "Resolved thread for unresolve test",
resolved: true,
});
// Unresolve by calling resolve-thread again (it toggles)
const unresolved = await user.page.evaluate(
async ({ threadId }) => {
const file = document.body.dataset["sourceFile"];
return docmd.call("threads:resolve-thread", {
file,
threadId,
resolved_by: "TestUser",
});
},
{ threadId },
);
expect(unresolved.resolved).toBeFalsy();
});
test("resolved thread gets --resolved CSS class on server render", async () => {
const { id: threadId } = await seedAndReload(user.page, {
body: "Thread for CSS test",
resolved: true,
});
// The resolved state requires an extra reload since seedThread does
// add-thread + resolve as separate file writes
await waitForReload(user.page);
const threadEl = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
await expect(threadEl).toBeAttached({ timeout: 5_000 });
// Server-rendered resolved threads should have the resolved class
const hasResolvedClass = await threadEl.evaluate((el) =>
el.classList.contains("threads-thread--resolved"),
);
expect(hasResolvedClass).toBe(true);
});
});
+326
View File
@@ -0,0 +1,326 @@
/**
* Playwright Screenplay primitives for e2e testing the threads plugin.
*
* Adapted from old-discussions-plugin/src/e2e/screenplay.ts for the new
* inline-only architecture (no sidebar panel, server-rendered threads,
* WebSocket actions instead of HTTP API).
*/
import type { Page } from "@playwright/test";
// ---------------------------------------------------------------------------
// Ability: BrowseTheWeb
// ---------------------------------------------------------------------------
export class BrowseTheWeb {
constructor(public readonly page: Page) {}
}
// ---------------------------------------------------------------------------
// Actor
// ---------------------------------------------------------------------------
export class Actor {
private browser: BrowseTheWeb;
constructor(
public readonly name: string,
page: Page,
) {
this.browser = new BrowseTheWeb(page);
}
get page(): Page {
return this.browser.page;
}
async perform<T>(task: Task<T>): Promise<T> {
return task.performAs(this);
}
async ask<T>(question: Question<T>): Promise<T> {
return question.answeredBy(this);
}
}
export interface Task<T> {
performAs(actor: Actor): Promise<T>;
}
export interface Question<T> {
answeredBy(actor: Actor): Promise<T>;
}
// ---------------------------------------------------------------------------
// Tasks
// ---------------------------------------------------------------------------
export class NavigateTo implements Task<void> {
constructor(private path: string) {}
async performAs(actor: Actor): Promise<void> {
await actor.page.goto(this.path);
// Wait for threads-app to be present (plugin loaded)
await actor.page.waitForSelector("threads-app", { state: "attached", timeout: 10_000 });
}
static path(path: string) {
return new NavigateTo(path);
}
}
export class SelectText implements Task<void> {
constructor(
private selector: string,
private text: string,
) {}
async performAs(actor: Actor): Promise<void> {
const sel = this.selector;
const txt = this.text;
const coords = await actor.page.evaluate(
({ selector, text }) => {
const elements = document.querySelectorAll(selector);
for (const el of elements) {
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
const idx = node.textContent?.indexOf(text) ?? -1;
if (idx >= 0) {
const range = document.createRange();
range.setStart(node, idx);
range.setEnd(node, idx + text.length);
const rects = range.getClientRects();
const startRect = rects[0];
const endRect = rects[rects.length - 1];
if (!startRect || !endRect) {
const rect = range.getBoundingClientRect();
return {
startX: rect.left,
startY: rect.top + rect.height / 2,
endX: rect.right,
endY: rect.top + rect.height / 2,
};
}
return {
startX: startRect.left,
startY: startRect.top + startRect.height / 2,
endX: endRect.right,
endY: endRect.top + endRect.height / 2,
};
}
}
}
return null;
},
{ selector: sel, text: txt },
);
if (!coords) throw new Error(`Text "${txt}" not found in ${sel}`);
await actor.page.mouse.move(coords.startX, coords.startY);
await actor.page.mouse.down();
await actor.page.mouse.move(coords.endX, coords.endY);
await actor.page.mouse.up();
}
static within(selector: string, text: string) {
return new SelectText(selector, text);
}
}
/**
* Select text and use the popover "Add comment" to open an inline editor,
* then fill and submit to create a new thread.
*/
export class StartThreadOn implements Task<void> {
constructor(
private selector: string,
private selectedText: string,
private comment: string,
) {}
async performAs(actor: Actor): Promise<void> {
await actor.perform(SelectText.within(this.selector, this.selectedText));
// Wait for popover and click "Add comment"
const popoverBtn = actor.page.locator("threads-popover").getByText("Add comment");
await popoverBtn.waitFor({ state: "visible", timeout: 5_000 });
await popoverBtn.click();
// Fill in the inline editor
const editor = actor.page.locator("threads-inline-editor");
await editor.waitFor({ state: "attached", timeout: 5_000 });
const textarea = editor.locator("wa-textarea").locator("textarea");
await textarea.waitFor({ timeout: 5_000 });
await textarea.fill(this.comment);
// Submit
const submitBtn = editor.locator("wa-button[variant='brand']");
await submitBtn.click();
// Wait for editor to detach (submission complete)
await editor.waitFor({ state: "detached", timeout: 10_000 });
}
static on(selector: string, selectedText: string, comment: string) {
return new StartThreadOn(selector, selectedText, comment);
}
}
/**
* Click the "New Thread" button and submit a general (unanchored) thread.
*/
export class AddNewThread implements Task<void> {
constructor(private body: string) {}
async performAs(actor: Actor): Promise<void> {
const newThreadBtn = actor.page.locator(".threads-new-thread-btn").first();
await newThreadBtn.click();
const editor = actor.page.locator("threads-inline-editor");
await editor.waitFor({ state: "attached", timeout: 5_000 });
const textarea = editor.locator("wa-textarea").locator("textarea");
await textarea.waitFor({ timeout: 5_000 });
await textarea.fill(this.body);
const submitBtn = editor.locator("wa-button[variant='brand']");
await submitBtn.click();
await editor.waitFor({ state: "detached", timeout: 10_000 });
}
static withBody(body: string) {
return new AddNewThread(body);
}
}
/**
* Click the Reply button on a server-rendered thread card and submit a reply.
*/
export class ReplyToThread implements Task<void> {
constructor(
private threadSelector: string,
private comment: string,
) {}
async performAs(actor: Actor): Promise<void> {
const threadEl = actor.page.locator(this.threadSelector);
const replyBtn = threadEl.locator(".threads-comment-reply-btn").first();
await replyBtn.click();
const editor = actor.page.locator("threads-inline-editor");
await editor.waitFor({ state: "attached", timeout: 5_000 });
const textarea = editor.locator("wa-textarea").locator("textarea");
await textarea.waitFor({ timeout: 5_000 });
await textarea.fill(this.comment);
const submitBtn = editor.locator("wa-button[variant='brand']");
await submitBtn.click();
await editor.waitFor({ state: "detached", timeout: 10_000 });
}
static on(threadSelector: string, comment: string) {
return new ReplyToThread(threadSelector, comment);
}
}
export class SubmitWithKeyboard implements Task<void> {
async performAs(actor: Actor): Promise<void> {
const editor = actor.page.locator("threads-inline-editor");
await editor.waitFor({ state: "attached", timeout: 3_000 });
const textarea = editor.locator("wa-textarea").locator("textarea");
await textarea.click();
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await actor.page.keyboard.press(`${modifier}+Enter`);
}
static inInlineEditor() {
return new SubmitWithKeyboard();
}
}
export class CancelWithKeyboard implements Task<void> {
async performAs(actor: Actor): Promise<void> {
const editor = actor.page.locator("threads-inline-editor");
await editor.waitFor({ state: "attached", timeout: 3_000 });
const textarea = editor.locator("wa-textarea").locator("textarea");
await textarea.click();
await actor.page.keyboard.press("Escape");
}
static inInlineEditor() {
return new CancelWithKeyboard();
}
}
// ---------------------------------------------------------------------------
// Questions
// ---------------------------------------------------------------------------
export class TheThreadCount implements Question<number> {
async answeredBy(actor: Actor): Promise<number> {
return actor.page.locator(".threads-thread[data-thread-id]").count();
}
static count() {
return new TheThreadCount();
}
}
export class TheHighlightCount implements Question<number> {
async answeredBy(actor: Actor): Promise<number> {
return actor.page.locator("mark.threads-highlight[data-thread-id]").count();
}
static count() {
return new TheHighlightCount();
}
}
export class TheInlineEditor implements Question<boolean> {
async answeredBy(actor: Actor): Promise<boolean> {
return (await actor.page.locator("threads-inline-editor").count()) > 0;
}
static isVisible() {
return new TheInlineEditor();
}
}
export class TheDeleteDialog implements Question<boolean> {
async answeredBy(actor: Actor): Promise<boolean> {
return actor.page.locator("wa-dialog#delete-dialog").isVisible();
}
static isOpen() {
return new TheDeleteDialog();
}
}
export class TheCommentBody implements Question<string> {
constructor(
private threadId: string,
private commentIndex: number,
) {}
async answeredBy(actor: Actor): Promise<string> {
const thread = actor.page.locator(`.threads-thread[data-thread-id="${this.threadId}"]`);
const comment = thread.locator(".threads-comment").nth(this.commentIndex);
const body = comment.locator(".threads-comment__body");
return (await body.textContent()) ?? "";
}
static of(threadId: string, commentIndex: number) {
return new TheCommentBody(threadId, commentIndex);
}
}
@@ -0,0 +1,215 @@
/**
* Thread creation tests - adapted from old devtalk.e2e.spec.ts "discussions" block
* and filters-general.e2e.spec.ts "general comments" block.
*
* Changes from old plugin:
* - No sidebar panel; threads are inline in the content area
* - "Add comment" popover opens inline editor (not a panel compose form)
* - "New Thread" button replaces "Add general comment" panel button
* - WebSocket actions instead of HTTP API for verification
*/
import { test, expect } from "@playwright/test";
import {
Actor,
NavigateTo,
StartThreadOn,
AddNewThread,
SelectText,
TheHighlightCount,
} from "./screenplay.ts";
import { cleanThreads, setAuthor, waitForReload } from "./helpers.ts";
const contentSelector = "[data-docmd-content] p, .docmd-content p, article p, main p";
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");
await cleanThreads(page);
// Reload to reflect the clean state
await waitForReload(page);
});
test.describe("creating threads via text selection", () => {
test("start a thread by selecting text", async () => {
await user.perform(NavigateTo.path("/"));
await user.page.waitForSelector(contentSelector, { timeout: 5_000 });
const firstParagraph = user.page.locator(contentSelector).first();
const paragraphText = await firstParagraph.textContent();
if (!paragraphText || paragraphText.trim().length < 5) {
test.skip(true, "No suitable text content found to select");
return;
}
const textToSelect = paragraphText.trim().slice(0, 20);
await user.perform(
StartThreadOn.on(contentSelector, textToSelect, "This needs clarification"),
);
// Wait for page to reload with the new thread
await waitForReload(user.page);
// Thread should appear in the content area
const threads = user.page.locator(".threads-thread[data-thread-id]");
// Count may include pre-existing threads from markdown, so just check we have at least 1
const count = await threads.count();
expect(count).toBeGreaterThanOrEqual(1);
});
test("highlights appear after creating a thread", async () => {
await user.perform(NavigateTo.path("/"));
await user.page.waitForSelector(contentSelector, { timeout: 5_000 });
const firstParagraph = user.page.locator(contentSelector).first();
const paragraphText = await firstParagraph.textContent();
if (!paragraphText || paragraphText.trim().length < 5) {
test.skip(true, "No suitable text content found");
return;
}
const highlightsBefore = await user.ask(TheHighlightCount.count());
const textToSelect = paragraphText.trim().slice(0, 15);
await user.perform(
StartThreadOn.on(contentSelector, textToSelect, "Highlight test"),
);
await waitForReload(user.page);
// At least one new highlight should have appeared
const highlightsAfter = await user.ask(TheHighlightCount.count());
expect(highlightsAfter).toBeGreaterThan(highlightsBefore);
});
test("popover shows 'Add comment' after text selection", async () => {
await user.perform(NavigateTo.path("/"));
await user.page.waitForSelector(contentSelector, { timeout: 5_000 });
const firstParagraph = user.page.locator(contentSelector).first();
const paragraphText = await firstParagraph.textContent();
if (!paragraphText || paragraphText.trim().length < 5) {
test.skip(true, "No suitable text content found");
return;
}
const textToSelect = paragraphText.trim().slice(0, 15);
await user.perform(SelectText.within(contentSelector, textToSelect));
const popoverBtn = user.page.locator("threads-popover").getByText("Add comment");
await expect(popoverBtn).toBeVisible({ timeout: 5_000 });
});
});
test.describe("creating general (unanchored) threads", () => {
test("clicking New Thread opens inline editor", async () => {
await user.perform(NavigateTo.path("/"));
const newThreadBtn = user.page.locator(".threads-new-thread-btn").first();
await newThreadBtn.click();
const editor = user.page.locator("threads-inline-editor");
await expect(editor).toBeAttached({ timeout: 5_000 });
});
test("submitting New Thread creates unanchored thread", async () => {
await user.perform(NavigateTo.path("/"));
await user.perform(AddNewThread.withBody("A general observation"));
// After submission, the page should reload and show the new thread
await waitForReload(user.page);
// Verify thread was persisted via WebSocket API
const threads = await user.page.evaluate(async () => {
const file = document.body.dataset["sourceFile"];
return docmd.call("threads:get-threads", { file });
});
const generalThread = threads.find(
(t: any) => t.comments[0]?.body === "A general observation",
);
expect(generalThread).toBeTruthy();
// Unanchored threads have no ==highlight== markup on the page
const highlightForThread = await user.page.locator(
`mark.threads-highlight[data-thread-id="${generalThread.id}"]`,
).count();
expect(highlightForThread).toBe(0);
});
test("general thread has no highlight on page", async () => {
await user.perform(NavigateTo.path("/"));
const highlightsBefore = await user.ask(TheHighlightCount.count());
await user.perform(AddNewThread.withBody("No highlight expected"));
await waitForReload(user.page);
// No new highlights should have appeared
const highlightsAfter = await user.ask(TheHighlightCount.count());
expect(highlightsAfter).toBe(highlightsBefore);
});
});
test.describe("popover behavior", () => {
test("mousedown outside popover dismisses it", async () => {
await user.perform(NavigateTo.path("/"));
await user.page.waitForSelector(contentSelector, { timeout: 5_000 });
const firstParagraph = user.page.locator(contentSelector).first();
const paragraphText = await firstParagraph.textContent();
if (!paragraphText || paragraphText.trim().length < 5) {
test.skip(true, "No suitable text content");
return;
}
const textToSelect = paragraphText.trim().slice(0, 15);
await user.perform(SelectText.within(contentSelector, textToSelect));
await expect(
user.page.locator("threads-popover").getByText("Add comment"),
).toBeVisible({ timeout: 5_000 });
// Click outside
await user.page.locator("h1, body").first().click();
await expect(
user.page.locator("threads-popover").getByText("Add comment"),
).not.toBeVisible({ timeout: 5_000 });
});
test("collapsing text selection hides popover", async () => {
await user.perform(NavigateTo.path("/"));
await user.page.waitForSelector(contentSelector, { timeout: 5_000 });
const firstParagraph = user.page.locator(contentSelector).first();
const paragraphText = await firstParagraph.textContent();
if (!paragraphText || paragraphText.trim().length < 5) {
test.skip(true, "No suitable text content");
return;
}
const textToSelect = paragraphText.trim().slice(0, 15);
await user.perform(SelectText.within(contentSelector, textToSelect));
await expect(
user.page.locator("threads-popover").getByText("Add comment"),
).toBeVisible({ timeout: 5_000 });
// Click to collapse selection
await user.page.mouse.click(10, 10);
await expect(
user.page.locator("threads-popover").getByText("Add comment"),
).not.toBeVisible({ timeout: 5_000 });
});
});
@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"types": ["node", "@playwright/test"],
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["**/*.ts"]
}