1
31import { API_BASE, DEMO_EMAIL, DEMO_PASSWORD, expect, test } from "../fixtures";
32import { createBlock, createPage, createWorkspace } from "../utils/api";
33import type { BlockDto } from "../fixtures";
34import type { APIRequestContext, Browser, Page } from "@playwright/test";
35
36
37const IMPROVED = (t: string) => `Improved: ${t.trim()}`;
38const SUMMARIZED = (t: string) => `Summarized: ${t.trim().slice(0, 80)}`;
39const TRANSLATED = (lang: string, t: string) => `Translated to ${lang}: ${t.trim()}`;
40
41
44async function authedTab(browser: Browser): Promise<{ page: Page; close: () => Promise<void> }> {
45
46
47
48 const plain = await browser.newContext({ baseURL: API_BASE });
49 const res = await plain.request.post("/api/v1/auth/login", {
50 data: { email: DEMO_EMAIL, password: DEMO_PASSWORD },
51 });
52 if (res.status() !== 200) throw new Error(`authedTab: login failed (${res.status()})`);
53 const jar = await plain.cookies();
54 await plain.close();
55 const context = await browser.newContext({
56 storageState: {
57 cookies: jar.map((c) => ({
58 name: c.name, value: c.value, domain: c.domain, path: c.path,
59 expires: c.expires, httpOnly: c.httpOnly, secure: c.secure, sameSite: c.sameSite,
60 })),
61 origins: [],
62 },
63 });
64 const page = await context.newPage();
65 return { page, close: () => context.close() };
66}
67
68
69async function seedPage(api: APIRequestContext): Promise<{ wsId: string; pageId: string; blocks: BlockDto[] }> {
70 const ws = await createWorkspace(api, `Writing ${Date.now()}`);
71 const page = await createPage(api, ws.id, { title: "Writing tools" });
72 const blocks: BlockDto[] = [];
73 const texts = ["alpha one", "beta two", "gamma three"];
74 for (let i = 0; i < texts.length; i++) {
75 blocks.push(await createBlock(api, page.id, {
76 type: "paragraph",
77 content: { text: texts[i] },
78 position: `${i + 1}.0`,
79 }));
80 }
81 return { wsId: ws.id, pageId: page.id, blocks };
82}
83
84
85const textOf = (b: BlockDto): string =>
86 typeof b.content === "string" ? b.content : String((b.content as { text?: unknown }).text ?? "");
87
88
90async function pickWriting(
91 page: Page,
92 blocks: BlockDto[],
93 action: "summarize" | "rewrite" | "improve" | "translate",
94 language?: string,
95) {
96 for (const b of blocks) {
97 await page.locator(`.block-row[data-block-id="${b.id}"]`).click({ modifiers: ["Control"] });
98 }
99 await expect(page.getByTestId("selection-toolbar")).toBeVisible();
100 await page.getByTestId("selection-ai-menu").click();
101 await page.getByTestId(`writing-action-${action}`).click();
102 if (language) await page.getByTestId(`writing-language-${language}`).click();
103 const diff = page.getByTestId("writing-diff");
104 await expect(diff).toBeVisible({ timeout: 20_000 });
105 return diff;
106}
107
108test.describe("Writing tools (TASK-0123)", () => {
109 test("select 3 → AI → Summarize → diff → Accept → all updated, realtime to a 2nd tab, positions unchanged", async ({
110 authPage,
111 api,
112 browser,
113 }) => {
114 const { wsId, pageId, blocks } = await seedPage(api);
115 const url = `/w/${wsId}/page/${pageId}`;
116 await authPage.goto(url);
117 await expect(authPage.locator(`.block-row[data-block-id="${blocks[0]!.id}"]`)).toBeVisible({ timeout: 20_000 });
118
119 const positionsBefore = blocks.map((b) => b.position);
120
121 await pickWriting(authPage, blocks, "summarize");
122
123
124 const diffBlocks = authPage.getByTestId("writing-diff-block");
125 await expect(diffBlocks).toHaveCount(3, { timeout: 20_000 });
126 for (const [i, b] of blocks.entries()) {
127 await expect(diffBlocks.nth(i)).toContainText(SUMMARIZED(textOf(b)));
128 }
129
130 await authPage.getByTestId("writing-accept").click();
131
132
133 await expect(authPage.getByTestId("writing-diff")).toHaveCount(0, { timeout: 20_000 });
134 const all = ((await (await api.get(`/api/v1/pages/${pageId}/blocks`)).json()) as { data: BlockDto[] }).data;
135 for (const [i, b] of blocks.entries()) {
136 const updated = all.find((x) => x.id === b.id)!;
137 expect(textOf(updated), `block ${i} text`).toBe(SUMMARIZED(textOf(b)));
138 }
139
140 for (const [i, b] of blocks.entries()) {
141 const updated = all.find((x) => x.id === b.id)!;
142 expect(updated.position, `block ${i} position`).toBe(positionsBefore[i]);
143 }
144
145
146 const tab2 = await authedTab(browser);
147 try {
148 await tab2.page.goto(url);
149 for (const [i, b] of blocks.entries()) {
150 await expect(
151 tab2.page.locator(`.block-row[data-block-id="${b.id}"]`),
152 `tab2 block ${i}`,
153 ).toContainText(SUMMARIZED(textOf(b)), { timeout: 15_000 });
154 }
155 } finally {
156 await tab2.close();
157 }
158 });
159
160 test("Translate with a language pick → Accept → text updated", async ({ authPage, api }) => {
161 const { wsId, pageId, blocks } = await seedPage(api);
162 await authPage.goto(`/w/${wsId}/page/${pageId}`);
163 await expect(authPage.locator(`.block-row[data-block-id="${blocks[0]!.id}"]`)).toBeVisible({ timeout: 20_000 });
164
165 await pickWriting(authPage, blocks.slice(0, 1), "translate", "es");
166
167 const diffBlocks = authPage.getByTestId("writing-diff-block");
168 await expect(diffBlocks).toHaveCount(1, { timeout: 20_000 });
169 await expect(diffBlocks.first()).toContainText(TRANSLATED("es", textOf(blocks[0]!)));
170
171 await authPage.getByTestId("writing-accept").click();
172 await expect(authPage.getByTestId("writing-diff")).toHaveCount(0, { timeout: 20_000 });
173 const all = ((await (await api.get(`/api/v1/pages/${pageId}/blocks`)).json()) as { data: BlockDto[] }).data;
174 const updated = all.find((x) => x.id === blocks[0]!.id)!;
175 expect(textOf(updated)).toBe(TRANSLATED("es", textOf(blocks[0]!)));
176 });
177
178 test("Discard → blocks byte-identical (no write)", async ({ authPage, api }) => {
179 const { wsId, pageId, blocks } = await seedPage(api);
180 await authPage.goto(`/w/${wsId}/page/${pageId}`);
181 await expect(authPage.locator(`.block-row[data-block-id="${blocks[0]!.id}"]`)).toBeVisible({ timeout: 20_000 });
182
183
184 const before = new Map(
185 blocks.map((b) => [b.id, JSON.stringify({ content: b.content, position: b.position })]),
186 );
187
188 await pickWriting(authPage, blocks, "improve");
189 await expect(authPage.getByTestId("writing-accept")).toBeEnabled();
190
191 await authPage.getByTestId("writing-discard").click();
192 await expect(authPage.getByTestId("writing-diff")).toHaveCount(0, { timeout: 20_000 });
193
194
195 const all = ((await (await api.get(`/api/v1/pages/${pageId}/blocks`)).json()) as { data: BlockDto[] }).data;
196 for (const b of blocks) {
197 const now = all.find((x) => x.id === b.id)!;
198 expect(JSON.stringify({ content: now.content, position: now.position })).toBe(before.get(b.id));
199 }
200 });
201
202 test("concurrent edit between suggest and apply → 409 → rebase prompt", async ({ authPage, api }) => {
203 const { wsId, pageId, blocks } = await seedPage(api);
204 await authPage.goto(`/w/${wsId}/page/${pageId}`);
205 await expect(authPage.locator(`.block-row[data-block-id="${blocks[0]!.id}"]`)).toBeVisible({ timeout: 20_000 });
206
207
208 await pickWriting(authPage, blocks.slice(0, 1), "improve");
209 const diffBlocks = authPage.getByTestId("writing-diff-block");
210 await expect(diffBlocks).toHaveCount(1, { timeout: 20_000 });
211 const target = blocks[0]!;
212
213
214 await api.patch(`/api/v1/blocks/${target.id}`, {
215 data: { content: { text: `${textOf(target)} — concurrently edited` } },
216 });
217
218
219 await authPage.getByTestId("writing-accept").click();
220 await expect(authPage.getByTestId("writing-rebase-prompt")).toBeVisible({ timeout: 20_000 });
221
222 const all = ((await (await api.get(`/api/v1/pages/${pageId}/blocks`)).json()) as { data: BlockDto[] }).data;
223 const now = all.find((x) => x.id === target.id)!;
224 expect(textOf(now)).toContain("concurrently edited");
225 });
226});
227