172 lines
5.2 KiB
TypeScript
172 lines
5.2 KiB
TypeScript
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 };
|