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>(name: string, args: unknown = {}): Promise { 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("get_activity_context"); expect(result.ok).toBe(true); return result; } async publish(activities = activityProposals) { const { revision } = await this.context(); const result = await this.tool("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 };