본문으로 건너뛰기
언어

@plott/webkit-testing0.4.0

실제로 깨졌던 일을 테스트에서 일부러 일으키는 도구 — 시간대·시계, 막히거나 가득 찬 저장소, 한글 조합 중 Enter, 키보드가 가린 화면, 배포 뒤 사라진 서버 액션, 브라우저 자동 번역, GTM dataLayer, 느리거나 끊기거나 밑줄 헤더를 버리는 업스트림, 순서가 뒤바뀐 응답, 하이드레이션 경고

설치

pnpm
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-testingNode·브라우저(DOM 이 없어도 된다)withTimeZone, tzProjects, defaultTimeZones, fakeNow, deferred, outOfOrder, flush, staleActionResponse, STALE_ACTION_HEADER, problemDetails
@plott/webkit-testing/domhappy-dom·jsdom·브라우저throwingStorage, installStorage, imeEnter, imeKey, fakeVisualViewport, fakeBrowserTranslate, fakeDataLayer, throwingDataLayer, matchMediaStub, fakeIntersectionObserver, fakeResizeObserver, fakeHistory, dragPointer, fakeClipboard
@plott/webkit-testing/httpNode 전용mockUpstream, keepAliveRace
@plott/webkit-testing/reactDOM + React 19expectNoHydrationWarnings, renderUnderStrictMode, countEffectRuns

붙이기

시간대는 테스트 안에서 process.env.TZ 를 바꾸지 않고 프로젝트마다 TZ 를 줍니다(vitest.config.ts).

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")] },});

시계는 오프셋이 없는 시각 문자열을 거절합니다.

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 지시자를 둡니다).

ts
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() 로 되돌립니다.

ts
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 는 헤더만 오고 본문이 멈추는 업스트림입니다.

ts
import { mockUpstream } from "@plott/webkit-testing/http";
await using up = await mockUpstream({ stallBodyAfterBytes: 5 });const res = await fetch(up.url, { signal: AbortSignal.timeout(300) });
tsx
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
problemDetailsSpring 의 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, throwingDataLayerGTM 데이터 모델에 남은 옛 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, 브라우저)를 씁니다.