feat: publish shared task and reply activities through MCP

This commit is contained in:
2026-10-07 21:38:33 +09:00
parent 48ef396ec1
commit 794859e4a4
53 changed files with 4076 additions and 453 deletions
+56
View File
@@ -0,0 +1,56 @@
import type { Page } from "@playwright/test";
export class ActivitiesPage {
constructor(readonly page: Page) {}
card(id: string) {
return this.page.locator(`[data-activity-id="${id}"]`);
}
async expand(id: string) {
await this.card(id).getByRole("button", { expanded: false }).click();
}
async openReply(id: string) {
await this.expand(id);
await this.card(id)
.getByRole("link", { name: /^Review reply to / })
.click();
}
complete(title: string) {
return this.page.getByRole("button", { name: `Complete ${title}`, exact: true });
}
restore(title: string) {
return this.page.getByRole("button", { name: `Restore ${title}`, exact: true });
}
get draft() {
return this.page.getByRole("textbox", { name: "Reply draft", exact: true });
}
get saveDraft() {
return this.page.getByRole("button", { name: "Save draft", exact: true });
}
get useProposedDraft() {
return this.page.getByRole("button", { name: "Use proposed draft", exact: true });
}
get later() {
return this.page.getByRole("button", { name: "Later", exact: true });
}
get bringBack() {
return this.page.getByRole("button", { name: "Bring back", exact: true });
}
get notNeeded() {
return this.page.getByRole("button", { name: "Not needed", exact: true });
}
get sources() {
return this.page.getByRole("region", { name: "Source excerpts" });
}
get details() {
return this.page.getByRole("region", { name: "Message details" });
}
action(title: string) {
return this.page
.getByRole("navigation", { name: "Conversation actions" })
.getByRole("button", { name: title, exact: true });
}
async saveReply(text: string) {
await this.draft.fill(text);
await this.saveDraft.click();
}
}
+3 -2
View File
@@ -1,4 +1,4 @@
import type { Locator, Page } from "@playwright/test";
import { expect, type Locator, type Page } from "@playwright/test";
export class AuthPage {
readonly name: Locator;
@@ -24,7 +24,8 @@ export class AuthPage {
}
async goTo(mode: "login" | "setup" = "login") {
await this.page.goto(`/${mode}`, { waitUntil: "networkidle" });
await this.page.goto(`/${mode}`);
await expect(this.email).toBeVisible();
}
async signIn(email: string, password: string) {
+2 -1
View File
@@ -1,4 +1,5 @@
import type { Locator, Page } from "@playwright/test";
import { navigateHydrated } from "./ready";
export class JournalPage {
readonly page: Page;
@@ -29,7 +30,7 @@ export class JournalPage {
this.summary = this.summaryDialog.getByRole("textbox", { name: "Entry summary" });
}
async goTo(search = "") {
await this.page.goto(`/journal${search}`);
await navigateHydrated(this.page, `/journal${search}`);
}
async correctWeight(weight: string) {
await this.weightEntries.getByRole("button", { name: "Correct" }).click();
+6 -3
View File
@@ -1,4 +1,4 @@
import type { Locator, Page } from "@playwright/test";
import { expect, type Locator, type Page } from "@playwright/test";
export class OnboardingPage {
readonly name: Locator;
@@ -12,11 +12,14 @@ export class OnboardingPage {
}
async goTo() {
await this.page.goto("/onboarding", { waitUntil: "networkidle" });
await this.page.goto("/onboarding");
await expect(
this.name.or(this.page.getByRole("textbox", { name: "Home input" })),
).toBeVisible();
}
async complete() {
await this.page.waitForLoadState("networkidle");
await expect(this.continueButton).toBeEnabled();
await this.continueButton.click();
await this.openWorkspace.click();
}
+5 -2
View File
@@ -1,4 +1,5 @@
import type { Locator, Page } from "@playwright/test";
import { expect, type Locator, type Page } from "@playwright/test";
import { navigateHydrated } from "./ready";
export class ReaderPage {
readonly rows: Locator;
@@ -30,7 +31,9 @@ export class ReaderPage {
}
async goTo(search = "") {
await this.page.goto(`/inbox${search}`, { waitUntil: "networkidle" });
await navigateHydrated(this.page, `/inbox${search}`);
if (new URL(this.page.url()).pathname === "/inbox")
await expect(this.searchInput).toBeVisible();
}
article(title: string) {
+12 -2
View File
@@ -1,4 +1,5 @@
import type { Locator, Page } from "@playwright/test";
import { expect, type Locator, type Page } from "@playwright/test";
import { navigateHydrated, waitForWorkspaceHydration } from "./ready";
export class VitalsPage {
readonly bodyHeading: Locator;
@@ -36,7 +37,16 @@ export class VitalsPage {
}
async goTo(search = "") {
await this.page.goto(`/vitals${search}`, { waitUntil: "networkidle" });
await navigateHydrated(this.page, `/vitals${search}`);
if (new URL(this.page.url()).pathname === "/vitals") await this.waitUntilReady();
}
async reload() {
await Promise.all([waitForWorkspaceHydration(this.page), this.page.reload()]);
}
async waitUntilReady() {
await expect(this.bodyHeading.or(this.environmentHeading)).toBeVisible();
}
async navigateTo(section: "Body" | "Environment") {
+18
View File
@@ -0,0 +1,18 @@
import type { Page } from "@playwright/test";
/** Register before navigation: this stream starts in a client effect after hydration. */
export function waitForWorkspaceHydration(page: Page) {
return page.waitForResponse((response) => {
if (response.status() !== 200) return false;
const path = new URL(response.url()).pathname;
return (
path === "/api/activities/events" ||
(response.request().isNavigationRequest() &&
["/login", "/setup", "/onboarding"].includes(path))
);
});
}
export async function navigateHydrated(page: Page, url: string) {
await Promise.all([waitForWorkspaceHydration(page), page.goto(url)]);
}