1
38import { test, expect, DEMO_EMAIL, DEMO_PASSWORD } from "../fixtures";
39import type { APIRequestContext, Browser, Page } from "@playwright/test";
40
41const CONVERGE_MS = 3_000;
42
43
44
45
49async function authedTab(browser: Browser): Promise<{ page: Page; close: () => Promise<void> }> {
50 const plain = await browser.newContext();
51 const res = await plain.request.post("/api/v1/auth/login", {
52 data: { email: DEMO_EMAIL, password: DEMO_PASSWORD },
53 });
54 if (res.status() !== 200) {
55 throw new Error(`authedTab: login failed (${res.status()}): ${await res.text()}`);
56 }
57 const jar = await plain.cookies();
58 await plain.close();
59 const context = await browser.newContext({
60 storageState: {
61 cookies: jar.map((c) => ({
62 name: c.name,
63 value: c.value,
64 domain: c.domain,
65 path: c.path,
66 expires: c.expires,
67 httpOnly: c.httpOnly,
68 secure: c.secure,
69 sameSite: c.sameSite,
70 })),
71 origins: [],
72 },
73 });
74 const page = await context.newPage();
75 return { page, close: () => context.close() };
76}
77
78
79async function seedPage(api: APIRequestContext) {
80 const wsRes = await api.post("/api/v1/workspaces", { data: { name: `Collab 2tab ${Date.now()}` } });
81 expect(wsRes.status()).toBe(201);
82 const { workspace } = (await wsRes.json()) as { workspace: { id: string } };
83 const pgRes = await api.post(`/api/v1/workspaces/${workspace.id}/pages`, {
84 data: { title: "Two-tab convergence" },
85 });
86 expect(pgRes.status()).toBe(201);
87 const { page: pg } = (await pgRes.json()) as { page: { id: string } };
88 for (const [i, text] of ["alpha", "beta", "gamma"].entries()) {
89 const r = await api.post("/api/v1/blocks", {
90 data: { pageId: pg.id, type: "paragraph", content: { text }, position: `${i + 1}.0` },
91 });
92 expect([201, 202]).toContain(r.status());
93 }
94 return { wsId: workspace.id, pageId: pg.id };
95}
96
97
98
99
100async function blockText(page: Page, nth: number): Promise<string> {
101 return (await page.locator('[data-testid^="block-"]').nth(nth).innerText()).trim();
102}
103
104
105async function typeInto(page: Page, nth: number, text: string): Promise<void> {
106 const block = page.locator('[data-testid^="block-"]').nth(nth);
107 await block.click();
108 await page.keyboard.press("End");
109 await page.keyboard.type(text);
110}
111
112
115async function cutNetwork(page: Page) {
116 await page.context().route(/\/realtime|\/api\/v1\/blocks/, (route) => route.abort());
117 return () => page.context().unroute(/\/realtime|\/api\/v1\/blocks/);
118}
119
120
121
122test.describe("TASK-0092 (b) — two-tab convergence (the refetch-hint overlay)", () => {
123 test("edits in BOTH tabs converge to the union", async ({ browser, api }) => {
124 const { wsId, pageId } = await seedPage(api);
125 const url = `/w/${wsId}/page/${pageId}`;
126 const A = await authedTab(browser);
127 const B = await authedTab(browser);
128 try {
129 await A.page.goto(url);
130 await B.page.goto(url);
131 await expect(A.page.locator('[data-testid^="block-"]').first()).toContainText("alpha");
132 await expect(B.page.locator('[data-testid^="block-"]').first()).toContainText("alpha");
133
134
135 await typeInto(A.page, 0, " AA");
136 await typeInto(B.page, 1, " BB");
137
138
139
140
141 await expect.poll(() => blockText(A.page, 0), { timeout: CONVERGE_MS * 4 }).toContain("AA");
142 await expect.poll(() => blockText(A.page, 1), { timeout: CONVERGE_MS * 4 }).toContain("BB");
143 await expect.poll(() => blockText(B.page, 0), { timeout: CONVERGE_MS * 4 }).toContain("AA");
144 await expect.poll(() => blockText(B.page, 1), { timeout: CONVERGE_MS * 4 }).toContain("BB");
145 } finally {
146 await A.close();
147 await B.close();
148 }
149 });
150
151 test("offline tab: the local edit survives the outage + converges after the network returns", async ({ browser, api }) => {
152 const { wsId, pageId } = await seedPage(api);
153 const url = `/w/${wsId}/page/${pageId}`;
154 const A = await authedTab(browser);
155 const B = await authedTab(browser);
156 try {
157 await A.page.goto(url);
158 await B.page.goto(url);
159 await expect(B.page.locator('[data-testid^="block-"]').first()).toContainText("alpha");
160
161 const restore = await cutNetwork(B.page);
162
163
164
165 await typeInto(B.page, 0, " OFF");
166 await typeInto(A.page, 0, " ON");
167
168 await expect.poll(() => blockText(A.page, 0), { timeout: CONVERGE_MS }).toContain("ON");
169
170
171
172
173
174 await restore();
175 await typeInto(B.page, 0, "X");
176
177
178 await expect.poll(() => blockText(A.page, 0), { timeout: CONVERGE_MS }).toContain("OFF");
179 await expect.poll(() => blockText(A.page, 0), { timeout: CONVERGE_MS }).toContain("ON");
180 await expect.poll(() => blockText(B.page, 0), { timeout: CONVERGE_MS }).toContain("OFF");
181 await expect.poll(() => blockText(B.page, 0), { timeout: CONVERGE_MS }).toContain("ON");
182 } finally {
183 await A.close();
184 await B.close();
185 }
186 });
187});
188