From 75f58ae1fa40149a30f50f01c3fdded732865ee6 Mon Sep 17 00:00:00 2001 From: caesiumy Date: Sun, 11 Oct 2026 23:12:04 +0900 Subject: [PATCH 1/2] =?UTF-8?q?test(og):=20OG=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=ED=9A=8C=EA=B7=80=EB=A5=BC=20=ED=94=BD?= =?UTF-8?q?=EC=85=80=EB=A1=9C=20=EA=B2=80=EC=82=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 og-image.spec.ts는 메타 태그만 봐서 satori 0.47이 OG 이미지 40장을 깨뜨려도 통과했다(#208). 앞 카드 위 점의 색, 앞 카드 테두리 위치, 글자 줄의 세로 위치를 canvas로 읽어 그때 깨진 세 가지를 각각 잡는다. --- e2e/og-image-layout.spec.ts | 166 ++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 e2e/og-image-layout.spec.ts diff --git a/e2e/og-image-layout.spec.ts b/e2e/og-image-layout.spec.ts new file mode 100644 index 00000000..04d95ba8 --- /dev/null +++ b/e2e/og-image-layout.spec.ts @@ -0,0 +1,166 @@ +import { test, expect, type Page } from "@playwright/test"; +import { TEST_POST_URL } from "./fixtures/test-posts"; + +/** + * OG 이미지가 실제로 어떻게 그려지는지 픽셀로 검사한다. + * + * og-image.spec.ts는 메타 태그와 응답 형식만 보므로, satori가 레이아웃 기본값을 + * 바꿔 그림이 깨져도 통과한다. satori 0.33 → 0.47(#218)에서 실제로 40장이 모두 + * 깨졌는데 CI는 초록이었다. 여기서는 그때 깨진 세 가지를 직접 잡는다. + * + * - 뒤 카드(absolute)가 앞 카드 위에 그려짐 → 앞 카드 안쪽 점의 색 + * - boxSizing 기본값이 content-box로 바뀜 → 앞 카드 테두리 위치 + * -

기본 여백 1em의 기준이 바뀜 → 글자 줄의 세로 위치 + * + * 템플릿 5개는 카드 구조가 같다(src/utils/og-templates/*.tsx). 기대값은 1200×630 + * 기준 픽셀 좌표이고, 텍스트 위치는 안티에일리어싱과 1px 반올림을 감안해 오차를 둔다. + */ + +interface OgMetrics { + width: number; + height: number; + /** 앞 카드 안쪽, 뒤 카드와 겹치고 글자가 없는 점의 RGB */ + frontCardFill: [number, number, number]; + /** y=300 가로줄에서 왼쪽부터 처음 나오는 테두리(어두운 픽셀)의 x */ + frontCardLeftBorderX: number; + /** x=90 세로줄(뒤 카드보다 왼쪽)에서 위부터 처음 나오는 테두리의 y */ + frontCardTopBorderY: number; + /** 카드 안쪽 글자 영역에서 글자가 있는 가로 띠들 [시작 y, 끝 y] */ + textBands: [number, number][]; +} + +async function measureOgImage(page: Page, path: string): Promise { + // 같은 오리진 문서에서 PNG를 받아 canvas로 픽셀을 읽는다 + await page.goto("/robots.txt"); + return page.evaluate(async ogPath => { + const res = await fetch(ogPath); + if (!res.ok) throw new Error(`${ogPath} → ${res.status}`); + const bitmap = await createImageBitmap(await res.blob()); + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + const ctx = canvas.getContext("2d")!; + ctx.drawImage(bitmap, 0, 0); + const { data, width, height } = ctx.getImageData( + 0, + 0, + bitmap.width, + bitmap.height + ); + const grey = (x: number, y: number) => { + const i = (y * width + x) * 4; + return (data[i] + data[i + 1] + data[i + 2]) / 3; + }; + const rgb = (x: number, y: number): [number, number, number] => { + const i = (y * width + x) * 4; + return [data[i], data[i + 1], data[i + 2]]; + }; + + let frontCardLeftBorderX = -1; + for (let x = 0; x < width; x++) { + if (grey(x, 300) < 80) { + frontCardLeftBorderX = x; + break; + } + } + let frontCardTopBorderY = -1; + for (let y = 0; y < height; y++) { + if (grey(90, y) < 80) { + frontCardTopBorderY = y; + break; + } + } + + // 글자 영역: 내용 div(x 128~1072)의 안쪽, 카드 테두리를 피해 y 70~560 + const textBands: [number, number][] = []; + let start = -1; + for (let y = 70; y < 560; y++) { + let hasText = false; + for (let x = 128; x < 1072; x++) { + if (grey(x, y) < 200) { + hasText = true; + break; + } + } + if (hasText && start < 0) start = y; + if (!hasText && start >= 0) { + textBands.push([start, y - 1]); + start = -1; + } + } + + return { + width, + height, + frontCardFill: rgb(1100, 300), + frontCardLeftBorderX, + frontCardTopBorderY, + textBands, + }; + }, path); +} + +/** 앞 카드 배경 #fefbfb. 뒤 카드(#ecebeb, opacity 0.9)가 덮으면 236 근처로 떨어진다 */ +function expectFrontCardOnTop(m: OgMetrics) { + for (const channel of m.frontCardFill) { + expect(channel, "앞 카드 안쪽이 뒤 카드 색으로 덮였다").toBeGreaterThan( + 248 + ); + } +} + +function expectCardGeometry(m: OgMetrics) { + expect([m.width, m.height]).toEqual([1200, 630]); + // margin 2rem(32px) + width 88%를 border-box로 계산한 값: (1200 - 1056) / 2 = 72 + expect(m.frontCardLeftBorderX).toBe(72); + // (630 - 504) / 2 = 63 + expect(m.frontCardTopBorderY).toBe(63); +} + +function expectNear(actual: number, expected: number, message: string) { + expect(Math.abs(actual - expected), `${message}: ${actual}`).toBeLessThan(4); +} + +test.describe("OG 이미지 - 렌더링 레이아웃 회귀", () => { + test("사이트 기본 OG: 앞 카드가 위에 있고 카드 위치가 고정돼야 함", async ({ + page, + }) => { + const m = await measureOgImage(page, "/og.png"); + + expectFrontCardOnTop(m); + expectCardGeometry(m); + // 가운데 정렬된 블로그 제목 한 줄 + 하단 도메인 + expect(m.textBands).toHaveLength(2); + expectNear(m.textBands[0][0], 260, "제목 위쪽 y"); + expectNear(m.textBands[1][0], 498, "하단 도메인 위쪽 y"); + }); + + test("시리즈 목록 OG: 제목과 부제목 사이 간격이 유지돼야 함", async ({ + page, + }) => { + const m = await measureOgImage(page, "/series/og.png"); + + expectFrontCardOnTop(m); + expectCardGeometry(m); + //

여백이 글자 크기 기준으로 커지면 두 줄 사이가 벌어지고 묶음 전체가 이동한다 + expect(m.textBands).toHaveLength(3); + expectNear(m.textBands[0][0], 229, "제목 위쪽 y"); + expectNear(m.textBands[1][0], 333, "부제목 위쪽 y"); + expectNear(m.textBands[2][0], 498, "하단 블로그명 위쪽 y"); + }); + + test("포스트 OG: 제목이 카드 위쪽에서 시작하고 하단 블로그명이 보여야 함", async ({ + page, + }) => { + const m = await measureOgImage(page, `${TEST_POST_URL}/og.png`); + + expectFrontCardOnTop(m); + expectCardGeometry(m); + // 제목 첫 줄 위쪽 y는 글자 모양에 따라 2~3px 달라지므로 범위로 본다. + // 내용 div top 87 +

여백 16px + 줄 높이 안의 여유 = 약 115. + // 여백이 72px(글자 크기 기준 1em)로 커지면 160 이상으로 밀린다. + const titleTop = m.textBands[0][0]; + expect(titleTop, `제목 위쪽 y: ${titleTop}`).toBeGreaterThanOrEqual(105); + expect(titleTop, `제목 위쪽 y: ${titleTop}`).toBeLessThanOrEqual(127); + const footer = m.textBands[m.textBands.length - 1]; + expectNear(footer[0], 498, "하단 블로그명 위쪽 y"); + }); +}); From 140a0129c426d4adc8200072284f30fd5c06513c Mon Sep 17 00:00:00 2001 From: caesiumy Date: Sun, 11 Oct 2026 23:50:04 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test(og):=20=EA=B8=80=EC=9E=90=20=EC=A4=84?= =?UTF-8?q?=EC=9D=B4=20=EB=B9=84=EC=97=88=EC=9D=84=20=EB=95=8C=20=EB=AA=85?= =?UTF-8?q?=ED=99=95=ED=9E=88=20=EC=8B=A4=ED=8C=A8=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=ED=94=BD=EC=85=80=20=EA=B8=B0=EC=A4=80=EA=B0=92=EC=9D=98=20?= =?UTF-8?q?=EA=B7=BC=EA=B1=B0=EB=A5=BC=20=EC=A3=BC=EC=84=9D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=82=A8=EA=B8=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/og-image-layout.spec.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/e2e/og-image-layout.spec.ts b/e2e/og-image-layout.spec.ts index 04d95ba8..feafc5d3 100644 --- a/e2e/og-image-layout.spec.ts +++ b/e2e/og-image-layout.spec.ts @@ -54,6 +54,7 @@ async function measureOgImage(page: Page, path: string): Promise { return [data[i], data[i + 1], data[i + 2]]; }; + // 테두리는 #000이고 그 바깥은 배경 #fefbfb, 뒤 카드 #ecebeb라서 80 미만이면 테두리다 let frontCardLeftBorderX = -1; for (let x = 0; x < width; x++) { if (grey(x, 300) < 80) { @@ -69,7 +70,9 @@ async function measureOgImage(page: Page, path: string): Promise { } } - // 글자 영역: 내용 div(x 128~1072)의 안쪽, 카드 테두리를 피해 y 70~560 + // 글자 영역: 내용 div(x 128~1072)의 안쪽, 카드 테두리를 피해 y 70~560. + // 200 미만을 글자로 본다. 검은 글자와 회색 부제목(#666)은 걸리고 앞 카드 배경 + // #fefbfb는 걸리지 않는다. 템플릿 색을 바꾸면 이 기준도 다시 볼 것 const textBands: [number, number][] = []; let start = -1; for (let y = 70; y < 560; y++) { @@ -115,6 +118,7 @@ function expectCardGeometry(m: OgMetrics) { expect(m.frontCardTopBorderY).toBe(63); } +/** 기대값과 ±3px 이내인지 본다 (안티에일리어싱과 1px 반올림 여유) */ function expectNear(actual: number, expected: number, message: string) { expect(Math.abs(actual - expected), `${message}: ${actual}`).toBeLessThan(4); } @@ -157,6 +161,12 @@ test.describe("OG 이미지 - 렌더링 레이아웃 회귀", () => { // 제목 첫 줄 위쪽 y는 글자 모양에 따라 2~3px 달라지므로 범위로 본다. // 내용 div top 87 +

여백 16px + 줄 높이 안의 여유 = 약 115. // 여백이 72px(글자 크기 기준 1em)로 커지면 160 이상으로 밀린다. + // 제목 줄과 하단 블로그명, 최소 두 줄이 있어야 한다 (폰트 로딩 실패 등으로 비면 + // 아래 인덱스 접근에서 TypeError 대신 이 단언으로 실패한다) + expect( + m.textBands.length, + `글자 줄 수: ${JSON.stringify(m.textBands)}` + ).toBeGreaterThan(1); const titleTop = m.textBands[0][0]; expect(titleTop, `제목 위쪽 y: ${titleTop}`).toBeGreaterThanOrEqual(105); expect(titleTop, `제목 위쪽 y: ${titleTop}`).toBeLessThanOrEqual(127);