chore: import upstream snapshot with attribution
docmd CI verification / verify (push) Failing after 0s
docmd CI verification / verify (push) Failing after 0s
This commit is contained in:
@@ -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
@@ -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>;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user