import { test, expect } from "@playwright/test"; async function signupAndGetAccessToken( request: import("@playwright/test").APIRequestContext, email: string, password: string, ): Promise { const signupRes = await request.post("/api/auth/signup", { headers: { "Content-Type": "application/json" }, data: { email, password }, }); const signupStatus = signupRes.status(); if (signupStatus !== 201) { // 디버깅용: CI 등에서 500 이 떨어질 때 응답 바디를 함께 로그로 출력해 원인을 파악할 수 있게 한다. console.log("[public-seo] signup error status=", signupStatus); try { const bodyText = await signupRes.text(); console.log("[public-seo] signup error body=", bodyText); } catch (e) { console.log("[public-seo] signup error: body read failed", e); } } expect(signupStatus).toBe(201); const setCookieHeader = signupRes.headers()["set-cookie"]; expect(setCookieHeader).toBeTruthy(); const cookieHeaderString = Array.isArray(setCookieHeader) ? setCookieHeader.join("; ") : (setCookieHeader as string); const match = cookieHeaderString.match(/pb_access=([^;]+)/); expect(match).not.toBeNull(); const accessToken = match![1]; return accessToken; } async function createProjectWithSnapshot( request: import("@playwright/test").APIRequestContext, accessToken: string, params: { slug: string; title: string; projectConfig: any }, ) { const { slug, title, projectConfig } = params; const res = await request.post("/api/projects", { headers: { "Content-Type": "application/json", Cookie: `pb_access=${accessToken}`, }, data: { title, slug, // 새 스펙: contentJson 은 blocks + projectConfig 스냅샷으로 보낸다. contentJson: { blocks: [], projectConfig, }, }, }); const status = res.status(); if (status !== 201) { console.log("[public-seo] createProject error status=", status); try { const bodyText = await res.text(); console.log("[public-seo] createProject error body=", bodyText); } catch (e) { console.log("[public-seo] createProject error: body read failed", e); } } expect(status).toBe(201); } async function patchProjectStatus( request: import("@playwright/test").APIRequestContext, accessToken: string, slug: string, status: "DRAFT" | "PUBLISHED" | "ARCHIVED", ) { const res = await request.patch(`/api/projects/${slug}`, { headers: { "Content-Type": "application/json", Cookie: `pb_access=${accessToken}`, }, data: { status }, }); const code = res.status(); if (code !== 200) { console.log("[public-seo] patchProjectStatus error status=", code); try { const bodyText = await res.text(); console.log("[public-seo] patchProjectStatus error body=", bodyText); } catch (e) { console.log("[public-seo] patchProjectStatus error: body read failed", e); } } expect(code).toBe(200); } // 퍼블릭 페이지(/p/[slug])에서 프로젝트 SEO 설정이 실제 /메타 태그/스크립트에 반영되는지 검증하는 E2E. // - seoTitle/seoDescription/seoCanonicalUrl/seoOgImageUrl → title 및 OG/Twitter 메타 // - seoNoIndex=true → <meta name="robots" content="noindex, nofollow"> 존재 // - seoCanonicalUrl 은 canonical <link> 가 아니라 og:url 로만 노출되어야 한다. // - headHtml 은 <head> 에 그대로 삽입되고, trackingScript 는 <body> 에 삽입/실행되어야 한다. test.describe("Public SEO metadata", () => { test("PUBLISHED 프로젝트의 SEO 설정이 <title> 및 메타 태그에 반영되어야 한다", async ({ page, request }) => { const now = Date.now(); const email = `public-seo-${now}@example.com`; const password = "public-seo-password"; const slug = `public-seo-${now}`; const accessToken = await signupAndGetAccessToken(request, email, password); const seoTitle = "SEO 타이틀 - My landing"; const seoDescription = "SEO 설명입니다."; const seoUrl = `https://example.com/${slug}`; const seoImage = "https://example.com/og-image.png"; const projectConfig = { title: "에디터 타이틀", slug, canvasPreset: "full", canvasBgColorHex: "#020617", bodyBgColorHex: "#020617", seoTitle, seoDescription, seoCanonicalUrl: seoUrl, seoOgImageUrl: seoImage, seoNoIndex: false, headHtml: '<meta name="x-public-head" content="public-seo-test" />', trackingScript: "<script>window.__pbPublicSeoTracking = 'ok';</script>", }; await createProjectWithSnapshot(request, accessToken, { slug, title: projectConfig.title, projectConfig, }); await patchProjectStatus(request, accessToken, slug, "PUBLISHED"); const response = await page.goto(`/p/${slug}`); expect(response?.status()).toBe(200); // 1) <title> await expect(page).toHaveTitle(seoTitle); // 2) 기본/OG/Twitter 메타 태그 const metaDesc = page.locator('meta[name="description"]'); await expect(metaDesc).toHaveAttribute("content", seoDescription); const ogTitle = page.locator('meta[property="og:title"]'); await expect(ogTitle).toHaveAttribute("content", seoTitle); const ogDesc = page.locator('meta[property="og:description"]'); await expect(ogDesc).toHaveAttribute("content", seoDescription); const ogUrl = page.locator('meta[property="og:url"]'); await expect(ogUrl).toHaveAttribute("content", seoUrl); const ogImage = page.locator('meta[property="og:image"]'); await expect(ogImage).toHaveAttribute("content", seoImage); const twitterTitle = page.locator('meta[name="twitter:title"]'); await expect(twitterTitle).toHaveAttribute("content", seoTitle); // 3) seoNoIndex=false 인 경우 robots 메타는 없어야 한다. const robots = page.locator('meta[name="robots"]'); await expect(robots).toHaveCount(0); // 4) canonical <link> 는 생성되지 않아야 한다. const canonicalLink = page.locator('link[rel="canonical"]'); await expect(canonicalLink).toHaveCount(0); // 5) headHtml 스니펫이 <head> 에 삽입되어야 한다. const customHeadMeta = page.locator('meta[name="x-public-head"]'); await expect(customHeadMeta).toHaveAttribute("content", "public-seo-test"); // 6) trackingScript 가 <body> 에 삽입 및 실행되어 window 플래그를 남겨야 한다. const trackingValue = await page.evaluate(() => (window as any).__pbPublicSeoTracking ?? null); expect(trackingValue).toBe("ok"); }); test("seoNoIndex=true 인 PUBLISHED 프로젝트는 robots noindex 메타 태그를 포함해야 한다", async ({ page, request }) => { const now = Date.now(); const email = `public-seo-noindex-${now}@example.com`; const password = "public-seo-noindex-password"; const slug = `public-seo-noindex-${now}`; const accessToken = await signupAndGetAccessToken(request, email, password); const seoTitle = "Noindex SEO 타이틀"; const seoDescription = "Noindex SEO 설명입니다."; const projectConfig = { title: "Noindex 프로젝트", slug, canvasPreset: "full", canvasBgColorHex: "#020617", bodyBgColorHex: "#020617", seoTitle, seoDescription, seoCanonicalUrl: "", seoOgImageUrl: "", seoNoIndex: true, }; await createProjectWithSnapshot(request, accessToken, { slug, title: projectConfig.title, projectConfig, }); await patchProjectStatus(request, accessToken, slug, "PUBLISHED"); const response = await page.goto(`/p/${slug}`); expect(response?.status()).toBe(200); await expect(page).toHaveTitle(seoTitle); const robots = page.locator('meta[name="robots"]'); await expect(robots).toHaveAttribute("content", /noindex/); }); });