@plott/webkit-testing0.4.0
실제로 깨졌던 일을 테스트에서 일부러 일으키는 도구 — 시간대·시계, 막히거나 가득 찬 저장소, 한글 조합 중 Enter, 키보드가 가린 화면, 배포 뒤 사라진 서버 액션, 브라우저 자동 번역, GTM dataLayer, 느리거나 끊기거나 밑줄 헤더를 버리는 업스트림, 순서가 뒤바뀐 응답, 하이드레이션 경고
설치
pnpm add @plott/webkit-testing@0.4.0진입점(exports)
| import | 타입 | 파일 |
|---|---|---|
@plott/webkit-testing | ./dist/index.d.ts | ./dist/index.js |
@plott/webkit-testing/dom | ./dist/dom/index.d.ts | ./dist/dom/index.js |
@plott/webkit-testing/http | ./dist/http/index.d.ts | ./dist/http/index.js |
@plott/webkit-testing/react | ./dist/react/index.d.ts | ./dist/react/index.js |
실제로 깨졌던 일을 테스트에서 일부러 일으키는 도구 모음입니다. 읽는 사람은 앱의 테스트를 쓰는 프론트엔드 개발자입니다. 각 앱이 «webkit 의 기본값을 쓰면 그 사고가 안 난다» 를 자기 테스트로 증명하는 데 씁니다.
런타임 의존성이 없습니다. vitest 도 가져오지 않습니다(vi 가 필요한 도구는 인자로 받습니다). react·react-dom 은 ./react 에서만 쓰는 선택 peer 입니다. 이 패키지 자신의 테스트는 도구마다 «순진한 구현을 부숴 보이는» 테스트로 되어 있어, 사용법의 예이기도 합니다.
진입점
| 진입점 | 어디서 도나 | 내보내는 것 |
|---|---|---|
@plott/webkit-testing | Node·브라우저(DOM 이 없어도 된다) | withTimeZone, tzProjects, defaultTimeZones, fakeNow, deferred, outOfOrder, flush, staleActionResponse, STALE_ACTION_HEADER, problemDetails |
@plott/webkit-testing/dom | happy-dom·jsdom·브라우저 | throwingStorage, installStorage, imeEnter, imeKey, fakeVisualViewport, fakeBrowserTranslate, fakeDataLayer, throwingDataLayer, matchMediaStub, fakeIntersectionObserver, fakeResizeObserver, fakeHistory, dragPointer, fakeClipboard |
@plott/webkit-testing/http | Node 전용 | mockUpstream, keepAliveRace |
@plott/webkit-testing/react | DOM + React 19 | expectNoHydrationWarnings, renderUnderStrictMode, countEffectRuns |
붙이기
시간대는 테스트 안에서 process.env.TZ 를 바꾸지 않고 프로젝트마다 TZ 를 줍니다(vitest.config.ts).
import { defineConfig } from "vitest/config";import { defaultTimeZones, tzProjects } from "@plott/webkit-testing";
export default defineConfig({ test: { projects: [{ test: { name: "unit", include: ["test/**/*.test.ts"], exclude: ["test/**/*.tz.test.ts"] } }, ...tzProjects(defaultTimeZones, "test/**/*.tz.test.ts")] },});시계는 오프셋이 없는 시각 문자열을 거절합니다.
import { afterEach, vi } from "vitest";import { fakeNow } from "@plott/webkit-testing";
const clock = fakeNow(vi, "2026-11-03T00:30:00+09:00");clock.advance(60_000);afterEach(() => clock.restore());한글 입력기의 Enter 는 imeEnter(input, "safari") 가 compositionstart → compositionupdate → input → compositionend → keydown(Enter, 229) → keyup 순서로 보냅니다(파일 맨 위에 @vitest-environment happy-dom 지시자를 둡니다).
import { imeEnter, installStorage, throwingStorage } from "@plott/webkit-testing/dom";
declare const input: HTMLInputElement;
imeEnter(input, "safari");const undo = installStorage(globalThis, "localStorage", throwingStorage("quota"));컴포넌트 시험에 필요한 브라우저 API 는 전역에 설치하고 restore() 로 되돌립니다.
import { dragPointer, fakeClipboard, fakeHistory, fakeIntersectionObserver, fakeResizeObserver, imeKey } from "@plott/webkit-testing/dom";
declare const handle: HTMLElement;declare const tagInput: HTMLInputElement;
const io = fakeIntersectionObserver(window);const ro = fakeResizeObserver(window);const history = fakeHistory(window, { url: "/list" });const clipboard = fakeClipboard(window.navigator);
io.trigger(handle, { isIntersecting: true });ro.trigger(handle, { width: 320, height: 200 });history.popstate({ page: 2 });dragPointer(handle, { from: { x: 10, y: 10 }, to: { x: 120, y: 10 } });imeKey(tagInput, ",", { browser: "chrome", code: "Comma", text: "가" });await clipboard.clipboard.writeText("복사");clipboard.restore();stallBodyAfterBytes: 5 는 헤더만 오고 본문이 멈추는 업스트림입니다.
import { mockUpstream } from "@plott/webkit-testing/http";
await using up = await mockUpstream({ stallBodyAfterBytes: 5 });const res = await fetch(up.url, { signal: AbortSignal.timeout(300) });import { expectNoHydrationWarnings } from "@plott/webkit-testing/react";
declare function Calendar(props: { today: string }): React.ReactNode;
const { container, unmount } = await expectNoHydrationWarnings(() => <Calendar today="2026-11-03" />);withTimeZone(zone, { module, export, args }) 는 시간대를 준 새 Node 프로세스에서 함수를 실행하고 JSON 결과를 돌려줍니다(모듈 경로는 절대 경로나 file: URL). 이미 떠 있는 프로세스의 process.env.TZ 를 바꾸는 방식은 ICU 와 날짜 캐시 때문에 믿을 수 없어서 쓰지 않습니다.
반쯤 닫힌 연결(502)
keepAliveRace({ serverKeepAliveMs, proxyIdleMs }) 는 Node 의 keepAliveTimeout(기본 5초)이 앞단 로드밸런서의 idle(AWS ALB 기본 60초)보다 짧은지 따집니다. 짧으면 로드밸런서가 서버가 이미 닫은 연결로 요청을 보내 RST 를 받고 502 를 냅니다. 재현은 mockUpstream({ resetReusedSockets: true }) 를 http.Agent({ keepAlive: true }) 로 두 번 부르면 결정적으로 됩니다(두 번째 요청이 ECONNRESET). closeIdleAfterMs 는 모의 서버(= 업스트림)가 놀던 연결을 먼저 닫게 합니다. 프록시의 idle timeout 을 흉내 내는 값이 아닙니다. safe 는 서버가 프록시보다 marginMs(기본 1000ms) 이상 길어야 참입니다 — Node 24 의 http.Server.keepAliveTimeoutBuffer(기본 1000ms. node -p "new (require('node:http').Server)().keepAliveTimeoutBuffer" 로 읽습니다)와 같은 목적입니다. resetReusedSockets 가 끊은 요청은 requests 에 fault: "reset-reused-socket" 으로 남지만 served 에는 세지 않습니다(실제 경쟁에서는 업스트림이 그 요청을 보지 못합니다). undici·fetch 는 방금 끝낸 연결을 곧바로 재사용하지 않으므로, 통합 테스트에서는 요청 사이에 약 30ms 쉬어야 같은 소켓을 씁니다.
무엇을 일으키나
| 도구 | 재현하는 실제 사고 | 이를 막는 webkit 기본값 |
|---|---|---|
withTimeZone, tzProjects | 서버(UTC)와 브라우저(UTC+9 같은 양의 오프셋)가 다른 «오늘» 을 셈해 현지 시각 0–9시에 어제가 됨 | @plott/webkit-time 이 시간대를 늘 인자로 받음 |
fakeNow | 자정을 넘긴 화면, 모듈 상수로 굳은 «오늘», 타이머를 안 흘려 못 잡던 폴링 | @plott/webkit-time 의 useToday(자정·탭 복귀 때 다시 봄). 브릿지 제한 시간에 쓴 짝은 아래 deferred 행 |
deferred, outOfOrder, fakeNow | 앱(네이티브)이 두 요청에 순서를 바꿔 답하거나 제한 시간 뒤에 늦게 답해, 한 요청이 다른 요청의 답을 받거나 «시간 초과» 로 보인 화면이 «성공» 으로 뒤집힘 | @plott/webkit-native 의 createBridge().request(id 로 짝 맞춤, timeout 뒤 늦은 답 무시) — packages/native/test/fault-injection.test.ts |
deferred, outOfOrder (검색어 «a» 의 늦은 응답이 «ab» 의 새 응답을 덮음) | 늦게 온 옛 응답이 새 응답을 덮음 | 짝이 되는 webkit API 없음 — 앱이 AbortSignal 로 취소하거나 마지막 요청만 반영한다(@plott/webkit-api 는 signal 을 그대로 전달할 뿐 «마지막 요청 우선» 을 하지 않는다) |
staleActionResponse | 배포 뒤 열려 있던 화면의 모든 저장이 «일시적인 오류» 로 실패 | @plott/webkit-next/client 의 isStaleDeployment·recoverFromStaleDeployment(쿨다운 안에서는 한 번만 새로 고침) — packages/next/test/fault-injection.test.ts |
problemDetails | Spring 의 RFC 9457 오류 본문을 데이터로 읽거나 code·필드 오류를 잃음, 5xx 의 내부 문구가 화면에 샘 | @plott/webkit-api 의 ApiError.code·fieldErrors — packages/api/test/fault-injection.test.ts. @plott/webkit-next 의 failFromResponse·runAction(5xx 문구는 숨기고 4xx 필드만 옮김) — packages/next/test/fault-injection.test.ts |
throwingStorage, installStorage | 사파리 개인 창·쿠키 차단·가득 찬 저장소에서 localStorage.setItem 이 앱을 죽임 | @plott/webkit-browser 의 safeStorage·markHistory·returnToMark — packages/browser/test/fault-injection.test.ts |
imeEnter | 한글 조합을 확정한 Enter 가 제출까지 함(사파리는 compositionend 가 먼저), 안드로이드 keyCode 229 | @plott/webkit-browser 의 onEnter·isSubmitEnter({ trust229: false } 로 끔) — packages/browser/test/fault-injection.test.ts |
fakeVisualViewport | 모바일 키보드에 가린 입력창·시트(innerHeight 는 줄지 않음) | @plott/webkit-browser 의 watchViewport(--vv-* 변수, data-keyboard="open") — packages/browser/test/fault-injection.test.ts |
fakeBrowserTranslate | 구글 번역이 글 노드를 <font> 로 감싸 React 가 removeChild 에서 죽음 | 짝이 되는 webkit 패키지 없음(앱이 translate="no" 를 직접 단다). 이 도구가 DOM 을 바꾸는 동작은 packages/testing/test/dom-translate.test.tsx 가 본다 |
fakeDataLayer, throwingDataLayer | GTM 데이터 모델에 남은 옛 error_source 가 다음 이벤트에 실림, dataLayer.push 가 던져 앱이 죽음 | @plott/webkit-analytics 의 pushEvent·gtmAdapter(선언한 키를 매번 채움)와 createAnalytics().track(던지지 않음) — packages/analytics/test/fault-injection.test.ts |
matchMediaStub | 처음 한 번만 읽은 prefers-reduced-motion 이 설정을 바꿔도 안 따라감 | 짝이 되는 webkit 패키지 없음. 이 도구의 동작은 packages/testing/test/dom-media.test.ts 가 본다 |
mockUpstream({ dropUnderscoreHeaders }) | nginx 가 x_user_id 같은 밑줄 헤더를 버려 익명으로 처리됨 | @plott/webkit-api 의 defineHeaders(밑줄 이름은 정의하는 순간 거절) — packages/api/test/fault-injection.test.ts |
mockUpstream({ delayHeadersMs, stallBodyAfterBytes, neverEndingBody }) | 본문이 멈추거나 끝나지 않아 res.json() 이 영원히 대기 | @plott/webkit-api 의 readTimeoutMs(→ ApiNetworkError timeout)·errorBodyTimeoutMs — packages/api/test/fault-injection.test.ts |
mockUpstream({ respond: "html200" | "empty204", envelope200 }) | 200 인데 로그인 페이지·빈 본문·본문 속 오류 | @plott/webkit-api 의 ApiError non_json·envelope(empty204 짝은 없음) — packages/api/test/fault-injection.test.ts |
mockUpstream({ resetConnection, resetReusedSockets, closeIdleAfterMs }), keepAliveRace | 연결 재사용 중 RST, 간헐 502 | @plott/webkit-api 의 ApiNetworkError network·retry()(GET 은 다시 보내고, 멱등 키 없는 POST 는 보내지 않음) — packages/api/test/fault-injection.test.ts. 서버 keepAliveTimeout > 프록시 idle 은 배포 설정이라 keepAliveRace 가 따지기만 한다 |
expectNoHydrationWarnings | 렌더 중 Date.now()·Math.random() 으로 서버와 브라우저 화면이 어긋남 | @plott/webkit-time 의 서버가 정한 «오늘» 전달 |
renderUnderStrictMode, countEffectRuns | 정리를 빠뜨린 effect 가 개발 모드에서 두 번 걸림 | 각 hook 의 정리 함수 |
«막는 webkit 기본값» 은 해당 패키지의 ## 기본으로 지키는 것 이 정본입니다. 이 패키지는 다른 webkit 패키지에 의존하지 않으므로 «도구로 일으킨 사고를 webkit 으로 막는» 테스트는 각 패키지의 test/fault-injection.test.ts 에 두고, 위 표의 링크가 그 파일입니다. 각 파일은 «순진한 구현» 이 같은 도구에 실제로 깨지는 것을 먼저 보인 뒤 webkit 기본값이 막는 것을 보입니다.
기본으로 지키는 것
- 도구는 호스트 환경을 읽지 않습니다.
fakeNow는 오프셋 없는 시각 문자열을 거절합니다(실행 환경 시간대로 읽혀 결과가 달라지므로).withTimeZone은 시간대를 자식 프로세스의 환경으로 줍니다. - 도구가 만든 전역은 되돌릴 수 있습니다.
installStorage·fakeVisualViewport·matchMediaStub·fakeIntersectionObserver·fakeResizeObserver·fakeHistory·fakeClipboard는 원래 속성 서술자를 기억해restore()로 정확히 되돌립니다.mockUpstream은close()(또는await using)가 모든 연결·타이머를 끊습니다. - 도구가 만든 서버는 테스트를 붙잡지 않습니다. 멈춘 응답·끝나지 않는 본문·지연 중인 요청이 있어도
close()가 끝납니다. - 분석 도구는 던지지 않는 쪽을 시험합니다.
throwingDataLayer는push가 던지는 상황을 줍니다. - 적대적인 키를 믿지 않습니다. 저장소·dataLayer 모델은
__proto__·constructor키를 일반 키로 다룹니다.
그 밖
fakeBrowserTranslate는 글 노드를<font><font>로 갈아 끼운 DOM 을 만들 뿐, 번역 엔진을 흉내 내지 않습니다. happy-dom 이 던지는 예외는name이NotFoundError가 아닌DOMException이라 메시지(not a child)로 확인합니다.fakeDataLayer의 병합 규칙(객체는 재귀 병합, 배열은 교체,undefined는 삭제, push 시점에 값을 복사)은 이 도구가 따르는 모델입니다.flush()·outOfOrder().resolveInReverse는vi.useFakeTimers()아래에서도 멈추지 않습니다(MessageChannel로 다음 매크로태스크를 기다리며 가짜 시계는 흐르게 하지 않습니다).expectNoHydrationWarnings는 «서버» 렌더 동안window·document·navigator등을 전역에서 떼므로typeof window로 갈라 그리는 컴포넌트의 불일치가 잡힙니다. 서버 렌더 중의console.error·warn도 문제로 셉니다.tzProjects가 만든 프로젝트는 기본으로 루트 설정을 물려받고(extends: true)pool: "forks"입니다.test.env.TZ는 워커 프로세스가 뜰 때 들어가므로forks에서 믿을 수 있습니다.fakeDataLayer는gtag()가 push 하는arguments를 데이터가 아닌 명령으로 기록하고 모델에 합치지 않습니다(commands()로 읽습니다).mockUpstream({ envelope200: { preset, keys, code, message, data } })는 HTTP 200 에 오류 본문을 싣습니다. 기본 모양이 없어서preset: "code-body-msg"({ code, body, msg },@plott/webkit-api의envelope프리셋 이름과 같음)나keys: { code, data, message }로 키 이름을 정해야 하고, 둘 다 없으면RangeError입니다.preset위에keys일부를 덮을 수 있습니다.true는 받지 않습니다(TypeError). 값의 기본은code: 500,data: null,message: "Internal Server Error"입니다.problemDetails(status, { detail, title, type, instance, extra })는 RFC 9457 의 표준 필드만 이름을 알고, 백엔드가 정한 확장 필드(code·errors등)는extra: { code: "…" }로 이름까지 직접 적습니다.imeEnter(target, options)의options는 브라우저 이름 문자열이거나{ browser, text, key, code, startComposition }입니다.browser는"chrome"(조합 시작 → keydown 229 → 입력 → 조합 끝 → keyup),"safari"(조합 끝이 keydown 보다 먼저),"android-229"(조합 이벤트 없이 keyCode 229) 중 하나이고 그 밖의 값은RangeError입니다.startComposition: false는 이미 조합 중인 상태에서 시작합니다.imeKey(target, key, options)는 Enter 가 아닌 키(쉼표 등)가 조합 중에 일으키는 같은 이벤트 순서를 보냅니다. 태그 입력창이 쉼표로 항목을 나눌 때 조합 중 쉼표를 걸러내는지 봅니다.options는imeEnter와 같고(key제외)code("Comma")를 줄 수 있습니다.imeEnter의 조합 글자 기본은a이고text로 바꿉니다(한글·가나 등 실제 입력기 글자는text로 줍니다).matchMediaStub는 질의를 공백(콜론·괄호·쉼표·비교 기호 둘레 포함)과 대소문자를 맞춰 비교하므로(min-width:768px)와(min-width: 768px)는 같은 질의입니다.fakeIntersectionObserver(window)·fakeResizeObserver(window)는 전역 클래스를 바꿔observe·unobserve·disconnect를 기록하고,trigger(요소, …)가 그 요소를 보고 있는 관찰자 콜백을 한 번씩 부릅니다(부른 수를 돌려줍니다).fakeHistory(window, { url, state })는pushState·replaceState·back·forward·go가 항목과popstate를 만드는history를 설치합니다. 항목 범위를 벗어난 이동은 이벤트를 보내지 않습니다.popstate(state)는 현재 항목의 상태를 바꾸고 이벤트를 보냅니다.dragPointer(요소, { from, to, steps, pointerType, pointerId, button, cancel, moveTarget })는 요소에pointerdown,moveTarget(기본document)에pointermove를steps(기본 5)번, 마지막에pointerup(또는cancel: true면pointercancel)을 보냅니다. dnd-kit 의PointerSensor처럼pointerdown뒤에document에서 이동·끝을 듣는 코드를 시험하는 데 쓰며 dnd-kit 에는 의존하지 않습니다.fakeClipboard(window.navigator, { text, denied })는navigator.clipboard를 덮어씁니다.userEvent.setup()이 이미 바꿔 놓은 뒤에 불러도 이 스텁이 쓰이고,restore()는 그 앞의 값으로 되돌립니다.denied가 참이면 읽기·쓰기가NotAllowedError로 거절되고,writes는 쓴 글을 순서대로 모읍니다.deferred()는 형 인자를 생략하면void라서deferred<void>()처럼 쓰지 않아도 됩니다.keepAliveRace의recommended는 프록시 idle 에keepAliveHeadroomMs(기본 5000)·headersHeadroomMs(기본 6000)를 더한 값입니다.defaultTimeZones는UTC,America/Los_Angeles(음수 오프셋·서머타임),Asia/Kolkata(+05:30, 분 단위 오프셋),Pacific/Kiritimati(+14:00) 입니다. 특정 나라의 시간대는 호출 쪽이 목록에 더합니다.imeEnter의android-229는 «조합 이벤트 없이 모든 키가 keyCode 229» 인 키보드입니다. 기기마다 다르면key옵션으로 바꿉니다.mockUpstream의closeIdleAfterMs는 서버가 연결을 닫는 시각을 정할 뿐, 닫는 순간과 클라이언트의 전송이 겹치는 경쟁은resetReusedSockets로 결정적으로 일으킵니다.- 모든
Response도구는 전역Response(Node 24, 브라우저)를 씁니다.