Skip to content
Merged
140 changes: 140 additions & 0 deletions e2e/workspace-collaboration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { expect, test, type Browser, type BrowserContext, type Page } from "@pla
import { spawn, type ChildProcess } from "node:child_process";
import { createConnection } from "node:net";
import { kill as killProcess } from "node:process";
import type { WorkspaceToolContext } from "../src/lib/workspace/tool-context";

const supabaseUrl = process.env.LOCAL_SUPABASE_URL;
const anonKey = process.env.LOCAL_SUPABASE_ANON_KEY;
Expand Down Expand Up @@ -348,3 +349,142 @@ test("real local users collaborate, recover, review, restore, and revoke access"
await Promise.allSettled(ids.map((id) => admin.auth.admin.deleteUser(id)));
}
});

test("workspace evidence, team AI review, and practice survive a teammate reload", async ({ browser }, testInfo) => {
test.skip(testInfo.project.name === "mobile-chromium", "The core mobile collaboration journey runs above");
test.setTimeout(120_000);
const admin = createClient(supabaseUrl!, serviceKey!, { auth: { persistSession: false } });
const password = `Local-${crypto.randomUUID()}!`;
const ids: string[] = [];
const contexts: BrowserContext[] = [];
let collaboration: ChildProcess | undefined;

try {
const sessions: Session[] = [];
for (const prefix of ["practice-owner", "practice-member"]) {
const email = `${prefix}-${Date.now()}-${crypto.randomUUID()}@workspace.test`;
const created = await admin.auth.admin.createUser({ email, email_confirm: true, password });
if (created.error || !created.data.user) throw created.error ?? new Error("User creation failed");
ids.push(created.data.user.id);
const client = createClient(supabaseUrl!, anonKey!, { auth: { persistSession: false } });
const signedIn = await client.auth.signInWithPassword({ email, password });
if (signedIn.error || !signedIn.data.session) throw signedIn.error ?? new Error("Sign in failed");
sessions.push(signedIn.data.session);
}

collaboration = await startCollaboration();
const owner = await authenticatedPage(browser, sessions[0]);
const member = await authenticatedPage(browser, sessions[1]);
contexts.push(owner.context, member.context);
await owner.page.route("**/functions/v1/ai", async (route) => {
const body = route.request().postDataJSON() as {
input?: { message?: string };
workspaceContext?: WorkspaceToolContext;
};
const workspaceContext = body.workspaceContext;
if (workspaceContext?.threadId && workspaceContext.threadRequest && body.input?.message) {
const started = await admin.rpc("start_workspace_thread_generation", {
p_assistant_message_id: workspaceContext.threadRequest.assistantMessageId,
p_content: body.input.message,
p_request_id: workspaceContext.requestId,
p_thread_id: workspaceContext.threadId,
p_user_id: ids[0],
p_user_message_id: workspaceContext.threadRequest.userMessageId,
p_workspace_id: workspaceContext.workspaceId,
});
if (started.error) throw started.error;
}
await route.fulfill({
body: JSON.stringify({ output: { response: "Phones should be limited with a clear enforcement mechanism.", followUpQuestions: [], uncertainties: [] } }),
contentType: "application/json",
status: 200,
});
});

await owner.page.goto("/app/workspaces");
await owner.page.getByRole("textbox", { name: "Motion" }).fill("Schools should limit phones");
await owner.page.getByRole("button", { name: "Create workspace" }).click();
await expect(owner.page).toHaveURL(/\/app\/workspaces\/[0-9a-f-]+$/, { timeout: 20_000 });
const workspaceUrl = owner.page.url();
const workspaceId = workspaceUrl.split("/").at(-1)!;
const membership = await admin.from("workspace_members").insert({ workspace_id: workspaceId, user_id: ids[1] });
if (membership.error) throw membership.error;

await owner.page.getByRole("button", { name: "R1" }).click();
await owner.page.locator(".ProseMirror").fill("Phones interrupt learning");
await expect(owner.page.getByText("Saved", { exact: true })).toBeVisible();

await openPanel(owner.page, "Evidence");
await owner.page.getByLabel("Source title").fill("Attention study");
await owner.page.getByLabel("Quoted evidence").fill("Notifications reduce sustained attention.");
await owner.page.getByRole("button", { name: "Add evidence" }).click();
await expect(owner.page.getByText("Attention study", { exact: true })).toBeVisible();
await owner.page.getByRole("button", { name: "Insert citation" }).click();
await expect(owner.page.locator(".ProseMirror")).toContainText("Attention study");

await openPanel(owner.page, "Team threads");
await owner.page.getByLabel("Thread name").fill("Both sides");
await owner.page.getByRole("button", { name: "Create", exact: true }).click();
await owner.page.getByLabel("Team message").fill("Compare both sides");
await owner.page.getByRole("button", { name: "Send", exact: true }).click();
let pendingMessage: { request_id: string; thread_id: string } | null = null;
await expect.poll(async () => {
const result = await admin.from("workspace_thread_messages")
.select("request_id,thread_id").eq("workspace_id", workspaceId).eq("role", "assistant").eq("status", "progress").maybeSingle();
if (result.error) throw result.error;
pendingMessage = result.data;
return Boolean(pendingMessage);
}).toBe(true);
if (!pendingMessage) throw new Error("Thread progress was not stored");
const completed = await admin.rpc("complete_workspace_thread_generation", {
p_content: "Phones should be limited with a clear enforcement mechanism.",
p_request_id: pendingMessage.request_id,
p_status: "complete",
p_thread_id: pendingMessage.thread_id,
p_user_id: ids[0],
p_workspace_id: workspaceId,
});
if (completed.error) throw completed.error;
await expect(owner.page.getByText(/clear enforcement mechanism/)).toBeVisible();

await openPanel(owner.page, "Practice");
await owner.page.getByRole("button", { name: "Suggest AI rewrite" }).click();
await openPanel(owner.page, "Review");
await expect(owner.page.getByRole("button", { name: "Reject" })).toHaveCount(1);
await owner.page.getByRole("button", { name: "Reject" }).click();
await expect(owner.page.locator(".ProseMirror")).toContainText("Phones interrupt learning");
await openPanel(owner.page, "Practice");
await owner.page.getByRole("button", { name: "Suggest AI rewrite" }).click();
await openPanel(owner.page, "Review");
await expect(owner.page.getByRole("button", { name: "Accept" })).toHaveCount(1);
await owner.page.getByRole("button", { name: "Accept" }).click();
await expect(owner.page.locator(".ProseMirror")).toContainText("clear enforcement mechanism");

await openPanel(owner.page, "Practice");
await owner.page.getByRole("button", { name: "Crossfire", exact: true }).click();
await expect(owner.page).toHaveURL(/\/app\/crossfire$/);
await expect(owner.page.getByTestId("crossfire-resolution")).toHaveValue("Schools should limit phones");
await expect(owner.page.getByText(/Shared workspace session/)).toBeVisible();
await expect.poll(async () => {
const result = await admin.from("workspace_artifacts").select("id").eq("workspace_id", workspaceId).eq("tool", "crossfire");
if (result.error) throw result.error;
return result.data.length;
}).toBe(1);
await owner.page.getByRole("link", { name: "Return to workspace" }).click();

await member.page.goto(workspaceUrl);
await member.page.getByRole("button", { name: "R1" }).click();
await openPanel(member.page, "Practice");
await expect(member.page.locator("summary").filter({ hasText: /Crossfire.*progress/i })).toBeVisible();
await member.page.reload();
await openPanel(member.page, "Practice");
await expect(member.page.locator("summary").filter({ hasText: /Crossfire.*progress/i })).toBeVisible();

await owner.page.goto("/app/ask");
await expect(owner.page.getByText(/Shared workspace session/)).toHaveCount(0);
} finally {
await stop(collaboration);
await Promise.allSettled(contexts.map((context) => context.close()));
await Promise.allSettled(ids.map((id) => admin.auth.admin.deleteUser(id)));
}
});
11 changes: 8 additions & 3 deletions services/collaboration/src/postgres-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ describe("Postgres collaboration writer locks", () => {
});

it("appends and snapshots only while author membership and epoch are current", async () => {
const { snapshot } = proposalFixture();
const client = new FakeClient();
const pool = { connect: vi.fn().mockResolvedValue(client) };
const store = new PostgresDurableUpdateStore(pool);
Expand All @@ -104,7 +105,7 @@ describe("Postgres collaboration writer locks", () => {
documentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
epoch: 3,
mutationId: "a".repeat(43),
snapshot: new Uint8Array([3, 4]),
snapshot,
syncType: 2,
});

Expand All @@ -114,6 +115,8 @@ describe("Postgres collaboration writer locks", () => {
.toBeLessThan(appendQueries.findIndex((text) => text.includes("SELECT state.workspace_id, state.durable_sequence")));
expect(client.query.mock.calls.some(([text]) => String(text).startsWith("INSERT INTO private.workspace_document_updates"))).toBe(true);
expect(client.query.mock.calls.some(([text]) => String(text).startsWith("UPDATE private.workspace_document_state"))).toBe(true);
const projectionWrite = client.query.mock.calls.find(([text]) => String(text).startsWith("UPDATE private.workspace_document_state"));
expect(projectionWrite?.[1]?.[3]).toBe("Hello world");
expect(client.query).toHaveBeenCalledWith("COMMIT");
await release();
});
Expand Down Expand Up @@ -145,6 +148,7 @@ describe("Postgres collaboration writer locks", () => {
});

it("loads the current snapshot and durable acknowledgement from the writer session", async () => {
const { snapshot } = proposalFixture();
const client = new FakeClient();
const pool = { connect: vi.fn().mockResolvedValue(client) };
const store = new PostgresDurableUpdateStore(pool);
Expand All @@ -156,7 +160,7 @@ describe("Postgres collaboration writer locks", () => {
client.query.mockReset();
client.query.mockImplementation(async (text: string) => {
if (text.includes("SELECT snapshot")) {
return { rows: [{ snapshot: new Uint8Array([9, 8]) }] };
return { rows: [{ snapshot, text_projection: "" }] };
}
return {
rows: [{
Expand All @@ -171,7 +175,8 @@ describe("Postgres collaboration writer locks", () => {
await expect(store.loadSnapshot(
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
3,
)).resolves.toEqual(new Uint8Array([9, 8]));
)).resolves.toEqual(snapshot);
expect(client.query.mock.calls.some(([text]) => String(text).includes("SET text_projection"))).toBe(true);
await expect(store.find(
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
3,
Expand Down
43 changes: 34 additions & 9 deletions services/collaboration/src/postgres-store.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { createHash } from "node:crypto";
import { z } from "zod";
import * as Y from "yjs";
import { yXmlFragmentToProsemirrorJSON } from "@tiptap/y-tiptap";
import {
acceptProposalHunkInDocument,
deserializeProposal,
rejectProposalHunks,
serializeProposal,
} from "../../../src/lib/workspace/review";
import { validateWorkspaceDocument } from "../../../src/lib/workspace/schema";
import { validateWorkspaceDocument, WORKSPACE_CONTENT_FIELD, workspaceSchema } from "../../../src/lib/workspace/schema";
import type { DurableAppend, DurableUpdate, DurableUpdateStore } from "./server";

export interface PgClient {
Expand Down Expand Up @@ -42,7 +43,7 @@ const durableRowSchema = z.object({
sync_type: z.number(),
});
const storedUpdateRowSchema = durableRowSchema.extend({ mutation_id: z.string() });
const snapshotRowSchema = z.object({ snapshot: z.instanceof(Uint8Array) });
const snapshotRowSchema = z.object({ snapshot: z.instanceof(Uint8Array), text_projection: z.string().optional() });
const authorizationRowSchema = z.object({ allowed: z.boolean() });
const proposalRowSchema = z.object({
durable_sequence: bigintSchema,
Expand All @@ -52,6 +53,18 @@ const proposalRowSchema = z.object({
workspace_id: z.string().uuid(),
});

function snapshotText(snapshot: Uint8Array) {
const document = new Y.Doc();
try {
if (snapshot.byteLength) Y.applyUpdate(document, snapshot);
return workspaceSchema.nodeFromJSON(
yXmlFragmentToProsemirrorJSON(document.getXmlFragment(WORKSPACE_CONTENT_FIELD)),
).textContent.slice(0, 100_000);
} finally {
document.destroy();
}
}

export class PostgresDurableUpdateStore implements DurableUpdateStore {
private readonly writers = new Map<string, Writer>();

Expand Down Expand Up @@ -84,14 +97,26 @@ export class PostgresDurableUpdateStore implements DurableUpdateStore {
}

async loadSnapshot(documentId: string, epoch: number) {
const result = await this.getWriter(documentId, epoch).query(
`SELECT snapshot
const client = this.getWriter(documentId, epoch);
const result = await client.query(
`SELECT snapshot, text_projection
FROM private.workspace_document_state
WHERE document_id = $1 AND epoch = $2`,
[documentId, epoch],
);
const input = result.rows[0];
return input ? new Uint8Array(snapshotRowSchema.parse(input).snapshot) : undefined;
if (!input) return undefined;
const row = snapshotRowSchema.parse(input);
const snapshot = new Uint8Array(row.snapshot);
if (!row.text_projection) {
await client.query(
`UPDATE private.workspace_document_state
SET text_projection = $3
WHERE document_id = $1 AND epoch = $2 AND text_projection = ''`,
[documentId, epoch, snapshotText(snapshot)],
);
}
return snapshot;
}

async load(documentId: string, epoch: number): Promise<DurableUpdate[]> {
Expand Down Expand Up @@ -204,9 +229,9 @@ export class PostgresDurableUpdateStore implements DurableUpdateStore {
);
await client.query(
`UPDATE private.workspace_document_state
SET durable_sequence = $2, snapshot = $3, updated_at = now()
SET durable_sequence = $2, snapshot = $3, text_projection = $4, updated_at = now()
WHERE document_id = $1`,
[update.documentId, sequence, Buffer.from(update.snapshot)],
[update.documentId, sequence, Buffer.from(update.snapshot), snapshotText(update.snapshot)],
);
await client.query(
`UPDATE public.workspace_documents
Expand Down Expand Up @@ -374,9 +399,9 @@ export class PostgresDurableUpdateStore implements DurableUpdateStore {
);
await client.query(
`UPDATE private.workspace_document_state
SET durable_sequence = $2, snapshot = $3, updated_at = now()
SET durable_sequence = $2, snapshot = $3, text_projection = $4, updated_at = now()
WHERE document_id = $1`,
[input.documentId, sequence, Buffer.from(snapshot)],
[input.documentId, sequence, Buffer.from(snapshot), snapshotText(snapshot)],
);
await client.query(
`UPDATE public.workspace_documents
Expand Down
7 changes: 4 additions & 3 deletions src/components/features/sparring/SparringSetup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface SparringSetupValues {
interface SparringSetupProps {
onStart: (values: SparringSetupValues) => void;
isStarting?: boolean;
initialValues?: Partial<SparringSetupValues>;
}

const DIFFICULTIES: Array<{ id: SparringDifficulty; name: string }> = [
Expand All @@ -34,10 +35,10 @@ const DIFFICULTIES: Array<{ id: SparringDifficulty; name: string }> = [
{ id: 'hard', name: 'Ruthless' },
];

export function SparringSetup({ onStart, isStarting = false }: SparringSetupProps) {
export function SparringSetup({ onStart, isStarting = false, initialValues }: SparringSetupProps) {
const { t } = useTranslation();
const [motion, setMotion] = useState('');
const [userSide, setUserSide] = useState<SparringSide>('pro');
const [motion, setMotion] = useState(initialValues?.motion ?? '');
const [userSide, setUserSide] = useState<SparringSide>(initialValues?.userSide ?? 'pro');
const [format, setFormat] = useState<SparringFormat>('standard');
const [difficulty, setDifficulty] = useState<SparringDifficulty>('medium');
const [isOnline, setIsOnline] = useState(() => typeof navigator === 'undefined' || navigator.onLine);
Expand Down
Loading
Loading