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

327 lines
9.7 KiB
TypeScript

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