Skip to content
File

Blob: tests/e2e/specs/27-sites-publish.spec.ts

typescript239 lines
1import type { APIRequestContext, Page as PlaywrightPage } from "@playwright/test";
2import {
3 test,
4 expect,
5 createTestPage,
6 createTestWorkspace,
7 waitForDocEditorReady,
8 waitForPersistedSnapshot,
9 waitForTitleProjection,
10} from "../fixtures/bland-test";
11 
12function siteOrigin(baseUrl: string, slug: string): string {
13 const { port } = new URL(baseUrl);
14 return `http://${slug}.bland.localhost${port ? `:${port}` : ""}`;
15}
16 
17function authHeaders(token: string): Record<string, string> {
18 return { Authorization: `Bearer ${token}` };
19}
20 
21async function patchSite(
22 request: APIRequestContext,
23 accessToken: string,
24 workspaceId: string,
25 body: Record<string, unknown>,
26) {
27 const res = await request.patch(`/api/v1/workspaces/${workspaceId}/site`, {
28 data: body,
29 headers: authHeaders(accessToken),
30 });
31 if (!res.ok()) throw new Error(`PATCH site failed: ${res.status()} ${await res.text()}`);
32 return res.json() as Promise<{ site: { slug: string; published_at: string | null; home_page_id: string | null } }>;
33}
34 
35async function publishPage(
36 request: APIRequestContext,
37 accessToken: string,
38 workspaceId: string,
39 pageId: string,
40): Promise<void> {
41 const res = await request.post(`/api/v1/workspaces/${workspaceId}/site/pages/${pageId}`, {
42 headers: authHeaders(accessToken),
43 });
44 if (!res.ok()) throw new Error(`Publish page failed: ${res.status()} ${await res.text()}`);
45}
46 
47async function unpublishPage(
48 request: APIRequestContext,
49 accessToken: string,
50 workspaceId: string,
51 pageId: string,
52): Promise<void> {
53 const res = await request.delete(`/api/v1/workspaces/${workspaceId}/site/pages/${pageId}`, {
54 headers: authHeaders(accessToken),
55 });
56 if (!res.ok()) throw new Error(`Unpublish page failed: ${res.status()} ${await res.text()}`);
57}
58 
59async function readPageUpdatedAt(
60 request: APIRequestContext,
61 accessToken: string,
62 workspaceId: string,
63 pageId: string,
64): Promise<string> {
65 const res = await request.get(`/api/v1/workspaces/${workspaceId}/pages/${pageId}`, {
66 headers: authHeaders(accessToken),
67 });
68 if (!res.ok()) throw new Error(`Read page failed: ${res.status()} ${await res.text()}`);
69 const body = (await res.json()) as { page: { updated_at: string } };
70 return body.page.updated_at;
71}
72 
73async function waitForPageUpdatedAfter(
74 request: APIRequestContext,
75 accessToken: string,
76 workspaceId: string,
77 pageId: string,
78 previousUpdatedAt: string,
79): Promise<void> {
80 await expect
81 .poll(() => readPageUpdatedAt(request, accessToken, workspaceId, pageId), {
82 timeout: 30_000,
83 intervals: [500, 1000, 1500, 2000],
84 })
85 .not.toBe(previousUpdatedAt);
86}
87 
88async function typeIntoEditor(
89 page: PlaywrightPage,
90 accessToken: string,
91 workspaceId: string,
92 workspaceSlug: string,
93 pageId: string,
94 text: string,
95): Promise<void> {
96 const previousUpdatedAt = await readPageUpdatedAt(page.request, accessToken, workspaceId, pageId);
97 await page.goto(`/${workspaceSlug}/${pageId}`);
98 const editor = await waitForDocEditorReady(page, { editable: true });
99 await editor.click();
100 await page.keyboard.type(text);
101 await expect(editor).toContainText(text);
102 await waitForDocEditorReady(page, { editable: true, connected: true });
103 await waitForPersistedSnapshot(page, accessToken, { workspaceId, pageId, expectedText: text });
104 await waitForPageUpdatedAfter(page.request, accessToken, workspaceId, pageId, previousUpdatedAt);
105 // The save-side page projection is queued after the D1 metadata mirror. There
106 // is no non-public R2 readiness endpoint in the E2E harness, so cover the
107 // configured 5s queue batch timeout before first public navigation to avoid
108 // poisoning the public HTML cache with the create-time empty artifact.
109 await page.waitForTimeout(6500);
110}
111 
112async function retitlePage(
113 page: PlaywrightPage,
114 accessToken: string,
115 workspaceId: string,
116 workspaceSlug: string,
117 pageId: string,
118 title: string,
119): Promise<void> {
120 await page.goto(`/${workspaceSlug}/${pageId}`);
121 const titleInput = page.locator("main textarea[placeholder='Untitled']");
122 await titleInput.waitFor({ timeout: 30_000 });
123 await titleInput.click();
124 await page.keyboard.press("ControlOrMeta+a");
125 await page.keyboard.type(title);
126 await expect(titleInput).toHaveValue(title);
127 await expect(page.getByText("Connected")).toBeVisible({ timeout: 15_000 });
128 await waitForTitleProjection(page, accessToken, { workspaceId, pageId, title });
129}
130 
131async function gotoPublicDocWhenReady(
132 page: PlaywrightPage,
133 url: string,
134 expectedTitle: string,
135 expectedBody: string,
136): Promise<void> {
137 await expect(async () => {
138 const response = await page.goto(url);
139 expect(response?.status()).toBe(200);
140 await expect(page.locator("h1.site-title")).toHaveText(expectedTitle, { timeout: 1000 });
141 await expect(page.locator(".tiptap")).toContainText(expectedBody, { timeout: 1000 });
142 }).toPass({ timeout: 30_000, intervals: [500, 1000, 2000] });
143}
144 
145test.describe("sites - publish flow", () => {
146 test("owner publishes pages and cached public HTML can remain stale until TTL", async ({
147 authenticatedPage: { page, accessToken },
148 e2eContext,
149 browser,
150 }) => {
151 const workspace = await createTestWorkspace(page, accessToken, "Sites Publish");
152 const home = await createTestPage(page, accessToken, "Welcome", workspace);
153 const subpage = await createTestPage(page, accessToken, "Sub Page", workspace);
154 
155 await typeIntoEditor(
156 page,
157 accessToken,
158 workspace.workspaceId,
159 workspace.workspaceSlug,
160 home.pageId,
161 "Welcome to the published site.",
162 );
163 await typeIntoEditor(
164 page,
165 accessToken,
166 workspace.workspaceId,
167 workspace.workspaceSlug,
168 subpage.pageId,
169 "Sub page body content here.",
170 );
171 
172 const slug = `e2e-${Date.now().toString(36)}`;
173 await patchSite(page.request, accessToken, workspace.workspaceId, { slug, published: true });
174 
175 await publishPage(page.request, accessToken, workspace.workspaceId, home.pageId);
176 await publishPage(page.request, accessToken, workspace.workspaceId, subpage.pageId);
177 
178 await patchSite(page.request, accessToken, workspace.workspaceId, { home_page_id: home.pageId });
179 
180 const anonContext = await browser.newContext();
181 const anonPage = await anonContext.newPage();
182 const subpagePublicId = subpage.pageId.toLowerCase();
183 
184 try {
185 const homeUrl = `${siteOrigin(e2eContext.baseUrl, slug)}/`;
186 await gotoPublicDocWhenReady(anonPage, homeUrl, "Welcome", "Welcome to the published site.");
187 
188 const subUrl = `${siteOrigin(e2eContext.baseUrl, slug)}/sub-page-${subpagePublicId}`;
189 await gotoPublicDocWhenReady(anonPage, subUrl, "Sub Page", "Sub page body content here.");
190 
191 // Retitle the subpage. Previously cached public HTML is allowed to stay
192 // stale until the internal Sites cache TTL expires.
193 await retitlePage(
194 page,
195 accessToken,
196 workspace.workspaceId,
197 workspace.workspaceSlug,
198 subpage.pageId,
199 "Renamed Page",
200 );
201 const staleRenamedRes = await anonPage.request.get(subUrl, { maxRedirects: 0 });
202 expect(staleRenamedRes.status()).toBe(200);
203 const staleRenamedHtml = await staleRenamedRes.text();
204 expect(staleRenamedHtml).toContain("Sub Page");
205 expect(staleRenamedHtml).toContain("Sub page body content here.");
206 
207 const uncachedRenamedRes = await anonPage.request.get(
208 `${siteOrigin(e2eContext.baseUrl, slug)}/stale-sub-page-${subpagePublicId}`,
209 { maxRedirects: 0 },
210 );
211 expect(uncachedRenamedRes.status()).toBe(302);
212 expect(uncachedRenamedRes.headers()["location"]).toContain(`/renamed-page-${subpagePublicId}`);
213 
214 // Unpublishing the subpage makes an uncached canonical URL 404.
215 await unpublishPage(page.request, accessToken, workspace.workspaceId, subpage.pageId);
216 const goneRes = await anonPage.request.get(
217 `${siteOrigin(e2eContext.baseUrl, slug)}/renamed-page-${subpagePublicId}`,
218 );
219 expect(goneRes.status()).toBe(404);
220 
221 // Home stays reachable.
222 const stillHome = await anonPage.request.get(homeUrl);
223 expect(stillHome.status()).toBe(200);
224 
225 // Disabling the site can still leave previously cached HTML visible, but
226 // uncached public URLs fail closed.
227 await patchSite(page.request, accessToken, workspace.workspaceId, { published: false });
228 const staleHome = await anonPage.request.get(homeUrl);
229 expect(staleHome.status()).toBe(200);
230 const offline = await anonPage.request.get(
231 `${siteOrigin(e2eContext.baseUrl, slug)}/uncached-${home.pageId.toLowerCase()}`,
232 );
233 expect(offline.status()).toBe(404);
234 } finally {
235 await anonContext.close();
236 }
237 });
238});