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
+171
View File
@@ -0,0 +1,171 @@
import { randomUUID } from "node:crypto";
import { basename, dirname, isAbsolute } from "node:path";
import type { APIRequestContext } from "@playwright/test";
import type {
ActivityContext,
ActivityProposal,
ActivityReceipt,
} from "../src/features/activities/model";
import { openDatabase } from "../src/features/storage/database.server";
import { expect, test as base } from "./fixtures";
import { ActivitiesPage } from "./models/ActivitiesPage";
import { HomePage } from "./models/HomePage";
import { MessagesPage } from "./models/MessagesPage";
class ActivityMessagesPage extends MessagesPage {
override async goTo(search = "") {
await super.goTo(search);
await expect(this.rows.first()).toHaveCount(1);
}
}
class ActivityHomePage extends HomePage {
override async goTo(search = "") {
await super.goTo(search);
// The brief appears after the persisted activity snapshot has loaded and hydrated.
await expect(this.dailyBrief).toBeVisible();
}
}
export const activityProposals: ActivityProposal[] = [
{
id: "buy-detergent",
kind: "task",
title: "Buy detergent",
nextAction: "Buy one refill of the usual detergent",
recommendation: { section: "focus" },
sources: [],
},
...["Propose review times", "Send the completed documents"].map(
(title, index): ActivityProposal => ({
id: index === 0 ? "review-times" : "send-documents",
kind: "reply",
title,
nextAction:
index === 0
? "Read the review materials and choose two times"
: "Check the final PDF before sharing it",
recommendation: { section: "focus" },
sources: [
{
id: `request-${index}`,
kind: "message",
provider: "beeper",
targetId: "e2e-target",
accountId: "e2e-account",
chatId: "same-chat",
messageId: `message-${index}`,
observedAt: "2026-10-07T01:00:00Z",
excerpt:
index === 0
? "Please review the materials and suggest two times."
: "Please share the completed documents when they are ready.",
},
],
reply: {
recipientName: "Haruka Aoki",
sourceId: `request-${index}`,
context: "Arrange the design review",
draft:
index === 0
? "Could we meet on Tuesday or Thursday?"
: "The completed review documents are ready.",
},
}),
),
];
export class ActivityMcp {
constructor(readonly client: APIRequestContext) {}
async tool<T = Record<string, unknown>>(name: string, args: unknown = {}): Promise<T> {
const response = await this.client.post("/mcp", {
headers: { Accept: "application/json, text/event-stream" },
data: {
jsonrpc: "2.0",
id: randomUUID(),
method: "tools/call",
params: { name, arguments: args },
},
});
expect(response.status()).toBe(200);
const body = await response.text();
const data = body.startsWith("event:")
? body
.split("\n")
.find((line) => line.startsWith("data: "))
?.slice(6)
: body;
const rpc = JSON.parse(data ?? "null");
expect(rpc.error).toBeUndefined();
return JSON.parse(rpc.result.content[0].text) as T;
}
async context() {
const result = await this.tool<ActivityContext>("get_activity_context");
expect(result.ok).toBe(true);
return result;
}
async publish(activities = activityProposals) {
const { revision } = await this.context();
const result = await this.tool<ActivityReceipt>("publish_activities", {
requestId: randomUUID(),
expectedRevision: revision,
activities,
});
expect(result.ok).toBe(true);
return result;
}
}
function clearActivities() {
const path = process.env.TWITTER_LITE_E2E_DB_PATH;
if (
!path ||
!isAbsolute(path) ||
!basename(dirname(path)).startsWith("twitter-lite-e2e-") ||
basename(path) !== "workspace.sqlite"
)
throw new Error("Isolated E2E database is required.");
const database = openDatabase(path);
try {
database.$client.exec(
"DELETE FROM activities; DELETE FROM activity_receipts; DELETE FROM activity_revision;",
);
} finally {
database.$client.close();
}
}
export const test = base.extend<{
activitiesPage: ActivitiesPage;
activityMcp: ActivityMcp;
resetActivities: undefined;
}>({
homePage: async ({ page }, use) => {
await use(new ActivityHomePage(page));
},
messagesPage: async ({ page }, use) => {
await use(new ActivityMessagesPage(page));
},
resetActivities: [
// oxlint-disable-next-line no-empty-pattern -- Playwright requires destructured fixture arguments.
async ({}, use) => {
clearActivities();
await use(undefined);
clearActivities();
},
{ auto: true },
],
activitiesPage: async ({ page }, use) => {
await use(new ActivitiesPage(page));
},
activityMcp: async ({ playwright, baseURL }, use) => {
const client = await playwright.request.newContext({
baseURL,
extraHTTPHeaders: {},
storageState: { cookies: [], origins: [] },
});
try {
await use(new ActivityMcp(client));
} finally {
await client.dispose();
}
},
});
export { expect };
+142
View File
@@ -0,0 +1,142 @@
import { activityProposals, expect, test } from "../activity-fixtures";
import { ActivitiesPage } from "../models/ActivitiesPage";
test("shares published chores and reply activities with Home, Messages and MCP without losing user changes", async ({
page,
context,
homePage,
workspacePage,
messagesPage,
activitiesPage,
activityMcp,
}) => {
await homePage.goTo();
await expect(activitiesPage.card("buy-detergent")).toHaveCount(0);
expect((await activityMcp.context()).activities).toEqual([]);
await activityMcp.publish();
await expect(activitiesPage.card("buy-detergent")).toBeVisible();
await expect(activitiesPage.card("review-times")).toBeVisible();
await expect(activitiesPage.card("send-documents")).toBeVisible();
await activitiesPage.complete("Buy detergent").click();
await expect(activitiesPage.card("buy-detergent")).toHaveAttribute("data-status", "done");
await activitiesPage.openReply("review-times");
await expect(page).toHaveURL(/\/support\?.*activity=review-times/);
await expect(activitiesPage.details).toContainText(
"Read the review materials and choose two times",
);
await expect(activitiesPage.sources).toContainText(
"Please review the materials and suggest two times.",
);
await expect(activitiesPage.sources).toContainText("not the full conversation history");
await expect(activitiesPage.action("Send the completed documents")).toBeVisible();
const edited = "I checked the materials. Thursday at 14:00 works for me.";
await activitiesPage.saveReply(edited);
await expect
.poll(
async () =>
(await activityMcp.context()).activities.find((item) => item.id === "review-times")
?.userOverrides.draft,
)
.toBe(edited);
await activitiesPage.later.click();
await expect
.poll(
async () =>
(await activityMcp.context()).activities.find((item) => item.id === "review-times")
?.userState,
)
.toBe("deferred");
const before = await activityMcp.context();
expect(before.activities.find((item) => item.id === "send-documents")?.userState).toBe(
"available",
);
const refreshed = activityProposals.map((proposal) =>
proposal.kind === "reply"
? { ...proposal, reply: { ...proposal.reply, draft: "A newer agent suggestion" } }
: proposal,
);
await activityMcp.publish(refreshed);
const after = await activityMcp.context();
expect(after.activities).toHaveLength(3);
expect(after.activities.find((item) => item.id === "review-times")).toMatchObject({
userState: "deferred",
userOverrides: { draft: edited },
effective: { reply: { draft: edited } },
proposal: { reply: { draft: "A newer agent suggestion" } },
});
expect(after.activities.find((item) => item.id === "buy-detergent")?.userState).toBe("completed");
await page.reload();
await expect(activitiesPage.draft).toHaveValue(edited);
await expect(
activitiesPage.details.getByRole("button", {
name: /^(Send|Mark complete|Try sending)$/,
exact: true,
}),
).toHaveCount(0);
await activitiesPage.action("Send the completed documents").click();
await expect(activitiesPage.draft).toHaveValue("A newer agent suggestion");
await messagesPage.showConversationList();
await workspacePage.navigateTo("Home");
await expect(activitiesPage.card("review-times")).toContainText("Set aside");
const device = await context.newPage();
try {
const other = new ActivitiesPage(device);
await device.goto("/");
await expect(other.card("buy-detergent")).toHaveAttribute("data-status", "done");
await activityMcp.publish([
{
id: "later-chore",
kind: "task",
title: "Take the recycling out",
nextAction: "Bring the paper bag downstairs",
sources: [],
},
]);
await expect(activitiesPage.card("later-chore")).toBeVisible();
await expect(other.card("later-chore")).toBeVisible();
await device.reload();
await expect(other.card("later-chore")).toBeVisible();
} finally {
await device.close();
}
});
test("retries publication without duplicates and rejects invalid batches without partial writes", async ({
activityMcp,
}) => {
const input = {
requestId: "stable-publication",
expectedRevision: 0,
activities: activityProposals,
};
const receipt = await activityMcp.tool("publish_activities", input);
expect(receipt.ok).toBe(true);
expect(await activityMcp.tool("publish_activities", input)).toEqual(receipt);
expect(
await activityMcp.tool("publish_activities", { ...input, requestId: "stale" }),
).toMatchObject({ ok: false, error: { code: "revision-conflict" } });
const snapshot = await activityMcp.context();
expect(
await activityMcp.tool("publish_activities", {
requestId: "invalid",
expectedRevision: snapshot.revision,
activities: [
{
id: "valid-sibling",
kind: "task",
title: "Valid sibling",
nextAction: "Do the chore",
sources: [],
},
{
id: "invalid-sibling",
kind: "reply",
title: "Missing reply target",
nextAction: "Check",
sources: [],
},
],
}),
).toMatchObject({ ok: false, error: { code: "invalid-input" } });
expect(await activityMcp.context()).toEqual(snapshot);
});
+4 -4
View File
@@ -14,7 +14,7 @@ test("creates the owner once and enters onboarding with a real session", async (
db.$client.close();
await context.clearCookies();
await homePage.goTo();
await page.waitForLoadState("networkidle");
await expect(authPage.createAccountButton).toBeVisible();
await expect(page).toHaveURL(/\/setup$/);
expect((await a11y().analyze()).violations).toEqual([]);
await authPage.createAccount({
@@ -24,7 +24,7 @@ test("creates the owner once and enters onboarding with a real session", async (
setupCode: "isolated-e2e-setup-token-not-for-production",
});
await expect(page).toHaveURL(/\/onboarding$/);
await page.waitForLoadState("networkidle");
await expect(onboardingPage.continueButton).toBeEnabled();
await onboardingPage.complete();
await expect(homePage.greeting("Yuta")).toBeVisible();
});
@@ -70,7 +70,7 @@ test("signs in and revokes the session on sign out", async ({
await authPage.password.fill("E2E-only-passphrase-2026");
await authPage.signInButton.click();
await expect(page).toHaveURL("/");
await page.waitForLoadState("networkidle");
await expect(workspacePage.manageAccounts).toBeVisible();
const token = (await context.cookies()).find((cookie) => cookie.name === "workspace_session");
expect(token?.httpOnly).toBe(true);
expect(token?.sameSite).toBe("Lax");
@@ -99,7 +99,7 @@ test("requires onboarding and remembers its completion and name", async ({
try {
expect((await request.get("/api/research/events")).status()).toBe(403);
await homePage.goTo();
await page.waitForLoadState("networkidle");
await expect(onboardingPage.name).toBeVisible();
await expect(page).toHaveURL(/\/onboarding$/);
expect((await a11y().analyze()).violations).toEqual([]);
await onboardingPage.name.fill("Yuta Test");
+58 -69
View File
@@ -1,4 +1,8 @@
import { expect, test } from "../fixtures";
import { expect, test } from "../activity-fixtures";
test.beforeEach(async ({ activityMcp }) => {
await activityMcp.publish();
});
test("executes routine steps and records a measurement in the shared journal", async ({
page,
@@ -8,7 +12,7 @@ test("executes routine steps and records a measurement in the shared journal", a
workspacePage,
}, testInfo) => {
await homePage.goTo();
await page.waitForLoadState("networkidle");
await page.waitForLoadState("domcontentloaded");
await homePage.morningRoutine.click();
await expect(page).toHaveURL(/activity=task/);
await homePage.routineStep(/Drink water/).check();
@@ -30,15 +34,16 @@ test("shows the day flow and conversation context at desktop width", async ({
page,
homePage,
messagesPage,
activitiesPage,
}, testInfo) => {
await page.setViewportSize({ width: 1440, height: 1080 });
await homePage.goTo();
await page.waitForLoadState("networkidle");
await page.waitForLoadState("domcontentloaded");
await expect(homePage.todayPlan).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("home-desktop.png") });
await messagesPage.goTo("?conversation=design-review");
await page.waitForLoadState("networkidle");
await expect(messagesPage.reply).toBeVisible();
await messagesPage.goTo("?activity=review-times&view=all");
await page.waitForLoadState("domcontentloaded");
await expect(activitiesPage.draft).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("messages-desktop.png") });
});
@@ -49,7 +54,7 @@ test("previews a natural-language memo and shares the executed record with journ
workspacePage,
}) => {
await homePage.goTo();
await page.waitForLoadState("networkidle");
await page.waitForLoadState("domcontentloaded");
const memo = "明日のレビューは導線から確認する";
await homePage.previewCommand(`Note: ${memo}`);
const execute = homePage.applyAction;
@@ -63,7 +68,7 @@ test("previews a natural-language memo and shares the executed record with journ
test("keeps the home usable on narrow screens", async ({ page, a11y, homePage }, testInfo) => {
await homePage.goTo();
await page.waitForLoadState("networkidle");
await page.waitForLoadState("domcontentloaded");
for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 850 });
await expect(homePage.input).toBeVisible();
@@ -73,80 +78,77 @@ test("keeps the home usable on narrow screens", async ({ page, a11y, homePage },
}
});
test("adds tasks only after confirmation and can defer and restore them", async ({
test("adds tasks only after confirmation and persists their deferred and completed state", async ({
page,
homePage,
activitiesPage,
activityMcp,
}) => {
await homePage.goTo();
await page.waitForLoadState("networkidle");
const complete = homePage.completeTask("grocery shopping");
const complete = activitiesPage.complete("grocery shopping");
await homePage.previewCommand("Add grocery shopping");
await expect(complete).toHaveCount(0);
await homePage.applyAction.click();
await expect(complete).toBeVisible();
await homePage.previewCommand("Defer grocery shopping");
await expect(complete).toBeVisible();
await homePage.applyAction.click();
await expect(complete).toHaveCount(0);
await homePage.restoreTask("grocery shopping").click();
await expect
.poll(
async () =>
(await activityMcp.context()).activities.find(
(item) => item.effective.title === "grocery shopping",
)?.userState,
)
.toBe("deferred");
await page.reload();
await complete.click();
await expect(complete).toHaveCount(0);
await expect(homePage.restoreTask("grocery shopping")).toBeVisible();
await expect(activitiesPage.restore("grocery shopping")).toBeVisible();
});
test("opens a conversation page from home and retains outcomes on return", async ({
test("opens a reply activity from home and retains its state through history and reload", async ({
page,
a11y,
homePage,
messagesPage,
workspacePage,
activitiesPage,
}) => {
await homePage.goTo();
await page.waitForLoadState("networkidle");
const conversationLink = homePage.conversation("Haruka Aoki");
await expect(conversationLink).toHaveAttribute("href", /conversation=design-review/);
await conversationLink.click();
await expect(page).toHaveURL(/\/support\?.*conversation=design-review/);
await expect(messagesPage.dialogs).toHaveCount(0);
await expect(messagesPage.reply).toBeVisible();
await activitiesPage.openReply("review-times");
await expect(page).toHaveURL(/activity=review-times/);
await expect(activitiesPage.draft).toBeVisible();
expect((await a11y().analyze()).violations).toEqual([]);
await page.goBack();
await expect(conversationLink).toBeVisible();
await expect(activitiesPage.card("review-times")).toBeVisible();
await page.goForward();
await expect(messagesPage.reply).toBeVisible();
await expect(activitiesPage.draft).toBeVisible();
await page.reload();
await page.waitForLoadState("networkidle");
await expect(messagesPage.reply).toBeVisible();
await messagesPage.leaveForLater.click();
await workspacePage.navigateTo("Home");
await expect(conversationLink).toContainText("Set aside");
await conversationLink.click();
await expect(page).toHaveURL(/conversation=design-review/);
await expect(messagesPage.moveToInbox).toBeVisible();
await messagesPage.moveToInbox.click();
await expect(activitiesPage.draft).toBeVisible();
await activitiesPage.later.click();
await messagesPage.showConversationList();
await workspacePage.navigateTo("Home");
await expect(conversationLink).toBeVisible();
await expect(activitiesPage.card("review-times")).toContainText("Set aside");
await activitiesPage.openReply("review-times");
await activitiesPage.bringBack.click();
await expect(activitiesPage.later).toBeVisible();
});
test("updates the prose brief as priorities change and opens the referenced conversation", async ({
page,
test("updates the prose brief as activities change and opens the referenced reply", async ({
homePage,
messagesPage,
workspacePage,
activitiesPage,
}) => {
await homePage.goTo();
await page.waitForLoadState("networkidle");
const brief = homePage.dailyBrief;
await expect(brief).toContainText("Review the documents");
await expect(brief).toContainText("3 conversations");
await homePage.completeTask("Review the documents").click();
await expect(brief).not.toContainText("Review the documents");
await homePage.openBriefConversation("Haruka Aoki");
await messagesPage.leaveForLater.click();
await expect(brief).toContainText("Buy detergent");
await activitiesPage.complete("Buy detergent").click();
await expect(brief).not.toContainText("Buy detergent");
await brief.getByRole("button", { name: "Propose review times", exact: true }).click();
await activitiesPage.later.click();
await messagesPage.showConversationList();
await workspacePage.navigateTo("Home");
await expect(brief).toContainText("2 conversations");
await expect(brief).not.toContainText("Haruka Aoki");
await expect(brief).not.toContainText("Propose review times");
await homePage.previewCommand("再開地点:資料の2ページ目から");
await homePage.applyAction.click();
await expect(brief).toContainText("資料の2ページ目から");
@@ -156,32 +158,19 @@ test("keeps completed actions in their original positions with visible outcomes"
page,
a11y,
homePage,
messagesPage,
workspacePage,
activitiesPage,
}, testInfo) => {
await homePage.goTo();
await page.waitForLoadState("networkidle");
const titles = homePage.flowTitles;
const before = await titles.allTextContents();
await homePage.completeTask("Review the documents").click();
await expect(homePage.restoreTask("Review the documents")).toHaveAttribute(
"aria-pressed",
"true",
);
await expect(homePage.completedTask("Review the documents")).toBeVisible();
await homePage.conversation("Haruka Aoki").click();
await messagesPage.reviewReply.click();
await messagesPage.trySending.click();
await workspacePage.navigateTo("Home");
await expect(homePage.conversation("Haruka Aoki")).toContainText("Reply recorded");
await expect(activitiesPage.card("send-documents")).toBeVisible();
const before = await homePage.flowTitles.allTextContents();
await activitiesPage.complete("Buy detergent").click();
await expect(activitiesPage.restore("Buy detergent")).toHaveAttribute("aria-pressed", "true");
await expect(activitiesPage.card("buy-detergent")).toContainText("Completed");
await homePage.recordMeasurement("65.2");
await expect(homePage.recordedWeight("65.2")).toBeVisible();
expect(await titles.allTextContents()).toEqual(before);
expect(await homePage.flowTitles.allTextContents()).toEqual(before);
expect((await a11y().analyze()).violations).toEqual([]);
await page.screenshot({ path: testInfo.outputPath("completion-in-place.png") });
await homePage.restoreTask("Review the documents").click();
await expect(homePage.completeTask("Review the documents")).toHaveAttribute(
"aria-pressed",
"false",
);
await activitiesPage.restore("Buy detergent").click();
await expect(activitiesPage.complete("Buy detergent")).toHaveAttribute("aria-pressed", "false");
});
+49 -51
View File
@@ -1,71 +1,68 @@
import { expect, test } from "../fixtures";
import { expect, test } from "../activity-fixtures";
const reviewSubject = "Schedule next week’s design review";
const coffeeSubject = "Catch up over coffee";
test.beforeEach(async ({ activityMcp }) => {
await activityMcp.publish();
});
test("reviews an edited reply and keeps the simulated result in history", async ({
test("saves an edited reply without recording a simulated send", async ({
page,
a11y,
messagesPage,
activitiesPage,
activityMcp,
}) => {
await messagesPage.goTo();
await page.waitForLoadState("networkidle");
const rows = messagesPage.rows;
await expect(rows).toHaveCount(3);
await messagesPage.conversation(reviewSubject).click();
await messagesPage.chooseReplySuggestion(/^Polite/);
const reply = messagesPage.reply;
await expect(reply).toHaveValue(/Thank you for sharing/);
await messagesPage.goTo("?activity=review-times&view=all");
const edited = "I reviewed the materials. Let’s discuss the first-time experience.";
await reply.fill(edited);
const slot = "Mon, Oct 5, 14:00–14:30";
await messagesPage.addSuggestedTime(slot);
await expect(reply).toHaveValue(`${edited}\n\nWould ${slot} work for you?`);
await messagesPage.reviewReply.click();
const dialog = messagesPage.reviewDialog;
await expect(dialog).toContainText(edited);
await expect(dialog).toContainText(slot);
await expect(dialog).toContainText("No message will be sent");
await activitiesPage.saveReply(edited);
await expect(page.getByRole("status").filter({ hasText: "Draft saved" })).toBeVisible();
await expect(activitiesPage.details).toContainText("Sending messages is not connected yet.");
await expect(
activitiesPage.details.getByRole("button", { name: "Mark complete", exact: true }),
).toHaveCount(0);
await page.reload();
await expect(activitiesPage.draft).toHaveValue(edited);
const activity = (await activityMcp.context()).activities.find(
(item) => item.id === "review-times",
);
expect(activity).toMatchObject({ userState: "available", userOverrides: { draft: edited } });
expect((await a11y().analyze()).violations).toEqual([]);
await messagesPage.editReply.click();
await expect(reply).toHaveValue(new RegExp(edited));
await messagesPage.reviewReply.click();
await messagesPage.trySending.click();
await expect(dialog).toHaveCount(0);
await expect(messagesPage.conversation(reviewSubject)).toHaveCount(0);
await messagesPage.selectView("Archive");
await messagesPage.conversation(reviewSubject).click();
await expect(messagesPage.outcome).toContainText(edited);
await expect(messagesPage.outcome).toContainText(slot);
});
test("defers a conversation and restores it to the current queue", async ({
page,
test("defers one reply while keeping another action in the same conversation available", async ({
messagesPage,
activitiesPage,
activityMcp,
}) => {
await messagesPage.goTo();
await page.waitForLoadState("networkidle");
await messagesPage.conversation(coffeeSubject).click();
await expect(messagesPage.conversation(coffeeSubject)).toHaveAttribute("aria-current", "true");
await messagesPage.reply.fill("土曜の予定を確認してから返事するね。");
await messagesPage.leaveForLater.click();
await expect(messagesPage.conversation(coffeeSubject)).toHaveCount(0);
await messagesPage.selectView("Set aside");
await messagesPage.conversation(coffeeSubject).click();
await expect(messagesPage.reply).toHaveValue("土曜の予定を確認してから返事するね。");
await messagesPage.moveToInbox.click();
await messagesPage.showConversationList();
await expect(messagesPage.conversation(coffeeSubject)).toBeVisible();
await expect(messagesPage.inbox).toHaveAttribute("aria-pressed", "true");
await messagesPage.goTo("?activity=review-times&view=all");
await activitiesPage.saveReply("土曜の予定を確認してから返事するね。");
await expect(activitiesPage.useProposedDraft).toBeVisible();
await activitiesPage.later.click();
await expect(activitiesPage.bringBack).toBeVisible();
await activitiesPage.action("Send the completed documents").click();
await expect(activitiesPage.details).toContainText("Ready when you are");
const snapshot = await activityMcp.context();
expect(snapshot.activities.find((item) => item.id === "review-times")?.userState).toBe(
"deferred",
);
expect(snapshot.activities.find((item) => item.id === "send-documents")?.userState).toBe(
"available",
);
await activitiesPage.action("Propose review times").click();
await expect(activitiesPage.draft).toHaveValue("土曜の予定を確認してから返事するね。");
await activitiesPage.bringBack.click();
await expect(activitiesPage.later).toBeVisible();
});
test("keeps corrections as new records and summarizes only selected current facts", async ({
page,
a11y,
journalPage,
homePage,
workspacePage,
}) => {
await journalPage.goTo();
await page.waitForLoadState("networkidle");
await homePage.goTo();
await workspacePage.navigateTo("Journal");
await page.waitForLoadState("domcontentloaded");
await expect(journalPage.saveEntry).toBeDisabled();
await journalPage.entryText.fill("次はレビュー資料の導線を確認する");
await journalPage.saveEntry.click();
@@ -97,16 +94,17 @@ test("fits communication and journal interactions in narrow screens", async ({
page,
a11y,
messagesPage,
activitiesPage,
journalPage,
workspacePage,
}, testInfo) => {
for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 850 });
await messagesPage.goTo();
await page.waitForLoadState("networkidle");
await page.waitForLoadState("domcontentloaded");
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
await messagesPage.rows.first().click();
await expect(messagesPage.reply).toBeVisible();
await expect(activitiesPage.draft).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
if (width === 320) expect((await a11y().analyze()).violations).toEqual([]);
await page.screenshot({ path: testInfo.outputPath(`messages-${width}.png`) });
+21 -11
View File
@@ -1,4 +1,14 @@
import { expect, test } from "../fixtures";
import type { Page } from "@playwright/test";
import { expect, test } from "../activity-fixtures";
import { waitForWorkspaceHydration } from "../models/ready";
async function reloadHydrated(page: Page) {
await Promise.all([waitForWorkspaceHydration(page), page.reload()]);
}
test.beforeEach(async ({ activityMcp }) => {
await activityMcp.publish();
});
test("restores article selection with browser history and reload", async ({ page, readerPage }) => {
await readerPage.goTo();
@@ -8,7 +18,7 @@ test("restores article selection with browser history and reload", async ({ page
await expect(readerPage.articleTitle).toHaveText(heading);
const selectedUrl = page.url();
expect(new URL(selectedUrl).searchParams.get("article")).toBeTruthy();
await page.reload({ waitUntil: "networkidle" });
await reloadHydrated(page);
await expect(readerPage.articleTitle).toHaveText(heading ?? "");
await page.goBack();
await expect(readerPage.workspace).toHaveAttribute("data-reading", "false");
@@ -24,14 +34,14 @@ test("restores search and replaces typing history", async ({
workspacePage,
}) => {
await homePage.goTo();
await page.waitForLoadState("networkidle");
await page.waitForLoadState("domcontentloaded");
await workspacePage.navigateTo("Reader");
const search = readerPage.searchInput;
await search.pressSequentially("browser");
await expect(search).toHaveValue("browser");
await expect.poll(() => new URL(page.url()).searchParams.get("q")).toBe("browser");
const count = await readerPage.rows.count();
await page.reload({ waitUntil: "networkidle" });
await reloadHydrated(page);
await expect(search).toHaveValue("browser");
await expect(readerPage.rows).toHaveCount(count);
await page.goBack();
@@ -46,7 +56,7 @@ test("restores account dialog without losing underlying article", async ({
await readerPage.goTo();
await connectedAccounts.open();
await expect.poll(() => new URL(page.url()).searchParams.get("accounts")).toBe("true");
await page.reload({ waitUntil: "networkidle" });
await reloadHydrated(page);
await expect(connectedAccounts.dialog).toBeVisible();
await page.goBack();
await expect(connectedAccounts.dialog).toHaveCount(0);
@@ -56,16 +66,16 @@ test("restores account dialog without losing underlying article", async ({
test("restores a message view and conversation from its URL", async ({ page, messagesPage }) => {
await messagesPage.goTo();
await page.waitForLoadState("networkidle");
await page.waitForLoadState("domcontentloaded");
await messagesPage.selectView("All");
const row = messagesPage.rows.nth(1);
const heading = await messagesPage.rowName(1).innerText();
const row = messagesPage.rows.first();
const heading = await messagesPage.rowName(0).innerText();
await row.click();
await expect(messagesPage.toolbarName).toHaveText(heading);
const url = page.url();
expect(new URL(url).searchParams.get("view")).toBe("all");
expect(new URL(url).searchParams.get("conversation")).toBeTruthy();
await page.reload({ waitUntil: "networkidle" });
expect(new URL(url).searchParams.get("activity")).toBeTruthy();
await reloadHydrated(page);
await expect(messagesPage.toolbarName).toHaveText(heading ?? "");
await expect(messagesPage.workspace).toHaveAttribute("data-reading", "true");
});
@@ -84,7 +94,7 @@ test("debounces search and cancels pending input on history navigation", async (
messagesPage,
}) => {
await messagesPage.goTo();
await page.waitForLoadState("networkidle");
await page.waitForLoadState("domcontentloaded");
await messagesPage.selectView("All");
await expect(page).toHaveURL(/view=all/);
await page.clock.install();
+6 -15
View File
@@ -7,8 +7,7 @@ test("filters body systems and opens lab history", async ({ page, vitalsPage, a1
await vitalsPage.selectSystem("Cardiovascular");
await expect(vitalsPage.systems).toHaveCount(1);
await expect(page).toHaveURL(/vitalTab=Cardiovascular/);
await page.reload();
await page.waitForLoadState("networkidle");
await vitalsPage.reload();
await expect(vitalsPage.systems).toHaveCount(1);
await vitalsPage.openFirstSystem();
const dialog = vitalsPage.systemDialog("Cardiovascular");
@@ -21,8 +20,7 @@ test("filters body systems and opens lab history", async ({ page, vitalsPage, a1
await vitalsPage.showLabHistory();
await expect(vitalsPage.history).toContainText("Jul 28");
await expect(vitalsPage.history).toContainText("93");
await page.reload();
await page.waitForLoadState("networkidle");
await vitalsPage.reload();
await expect(vitalsPage.history).toContainText("Jul 28");
await vitalsPage.showSystems();
await vitalsPage.selectSystem("Overview");
@@ -30,25 +28,19 @@ test("filters body systems and opens lab history", async ({ page, vitalsPage, a1
expect((await a11y().analyze()).violations).toEqual([]);
});
test("explores environment charts and simulated climate controls", async ({
page,
vitalsPage,
a11y,
}) => {
test("explores environment charts and simulated climate controls", async ({ vitalsPage, a11y }) => {
await vitalsPage.goTo();
await vitalsPage.navigateTo("Environment");
await expect(vitalsPage.environmentHeading).toBeVisible();
await vitalsPage.selectTrendPeriod("7D");
await vitalsPage.selectTrendMetric("CO₂");
await expect(vitalsPage.trendChart("7D", "CO₂")).toBeVisible();
await page.reload();
await page.waitForLoadState("networkidle");
await vitalsPage.reload();
await expect(vitalsPage.trendChart("7D", "CO₂")).toBeVisible();
await vitalsPage.openMetric("Room temperature");
const dialog = vitalsPage.metricDialog("Room temperature");
await expect(dialog).toContainText("64.0");
await page.reload();
await page.waitForLoadState("networkidle");
await vitalsPage.reload();
await expect(dialog).toContainText("64.0");
await vitalsPage.closeMetric("Room temperature");
await vitalsPage.syncReadings();
@@ -63,8 +55,7 @@ test("explores environment charts and simulated climate controls", async ({
await expect(temperature).toHaveValue("69");
await vitalsPage.turnOffPurifier();
await expect(vitalsPage.purifierOffButton).toHaveAttribute("aria-pressed", "false");
await page.reload();
await page.waitForLoadState("networkidle");
await vitalsPage.reload();
await expect(temperature).toHaveValue("68");
await expect(vitalsPage.autoButton).toHaveAttribute("aria-pressed", "true");
await expect(vitalsPage.purifierOnButton).toHaveAttribute("aria-pressed", "true");
+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)]);
}