<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>pengooseDev.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Thu, 31 Jul 2025 10:33:44 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>pengooseDev.log</title>
            <url>https://velog.velcdn.com/images/pengoose_dev/profile/d9da4443-4783-4a95-bccd-fafe93f2ac4e/social_profile.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. pengooseDev.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/pengoose_dev" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[yorkie#1 - 다른 기여자를 위한 기여]]></title>
            <link>https://velog.io/@pengoose_dev/yorkie1-%EB%8B%A4%EB%A5%B8-%EA%B8%B0%EC%97%AC%EC%9E%90%EB%A5%BC-%EC%9C%84%ED%95%9C-%EA%B8%B0%EC%97%AC</link>
            <guid>https://velog.io/@pengoose_dev/yorkie1-%EB%8B%A4%EB%A5%B8-%EA%B8%B0%EC%97%AC%EC%9E%90%EB%A5%BC-%EC%9C%84%ED%95%9C-%EA%B8%B0%EC%97%AC</guid>
            <pubDate>Thu, 31 Jul 2025 10:33:44 GMT</pubDate>
            <description><![CDATA[<p>Yorkie team에서의 첫 달이다.
1년 동안 오픈소스 활동을 하며 만들어진 나의 철학.</p>
<p>나에게 기여란 <code>PR의 merge</code>뿐 아니라, 팀의 관점에서 그들의 cost를 줄여주는 모든 것들이다.</p>
<p>오픈소스에서 feature나 fix의 PR을 날리는 것은 멋진 경험이었다.</p>
<p> 다만, 다른 기여자들이 쉽게 기여를 할 수 있는 환경과 분위기를 조성하고, 더 많은 사람들이 기여의 즐거움을 알도록 하는 것 또한 지속가능성의 기여이다.</p>
<p>그렇기에 예전엔 크게 매력을 느끼지 못했던 Docs를 작성하거나, test를 고치거나 ESLint의 에러를 고치는 등.
 프로젝트를 안정화하고 개발 환경 설정의 난이도를 낮추는 것 또한 굉장히 가치있는 일이라고 생각한다.</p>
<p>Yorkie에서의 첫 달은 이것에 집중하기로 했다.</p>
<hr>
<h2 id="yorkie-js-sdk">yorkie-js-sdk</h2>
<h3 id="1016-eslint-error-및-ci-test-fail">#1016 ESLint Error 및 CI Test fail</h3>
<p><a href="https://github.com/yorkie-team/yorkie-js-sdk/issues/1016">issue#1016</a>
<a href="https://github.com/yorkie-team/yorkie-js-sdk/issues/1028#event-18842701198">issue#1023</a></p>
<p><a href="https://github.com/yorkie-team/yorkie-js-sdk/pull/1023">PR#1023</a>
<a href="https://github.com/yorkie-team/yorkie-js-sdk/pull/1027">PR#1027</a></p>
<h3 id="1-개요">1. 개요</h3>
<ol>
<li>해당 project는 최신 pnpm version을 요구하는 환경과 deprecated된 ESLint 버전을 동시에 사용하는 환경이다.
또한, ESLint의존성이 root와 packages에 따로 존재하여, pnpm의 symlink로 참조되는 하위 패키지의 eslint plugin(@typescript-eslint)들을 적절히 load하지 못해 CI레벨의 참조 실패 warning이 예전부터 발생하였다. </li>
</ol>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/69753276-5045-4fe1-bbcf-e941923c4179/image.png" alt=""></p>
<h6 id="해당-pr을-생성하기-전-존재하던-ci-warningeslint-의존성-플러그인-참조-실패">해당 PR을 생성하기 전, 존재하던 CI warning(eslint 의존성 플러그인 참조 실패)</h6>
<ol>
<li>CWD에 따라 서로 다른 ESLint rule(eslint-typescript 또는 로드 실패시 적용되는 fallback rule인 core rule)이 적용되던 현상이 있었다.
이 문제를 해결하여 기여자들에게 동일한 개발 환경을 제공하고자 한다.</li>
<li><code>.npmrc</code>의 engine-strict를 사용하여 npm와 nodejs의 버전을 강제하지만, package.json에 pnpm에 버전이 누락되어 있어 pnpm의 버전을 강제하지 않고 있었다. 이러한 문제들을 수정하며 기여자들의 환경을 최대한 동기화 하고자 하였다.</li>
</ol>
<h3 id="2-issue-생성-및-토의">2. Issue 생성 및 토의</h3>
<p>직접 issue를 생성하고 바로 해결하는 것도 좋지만, 다른 기여자들이 참고할 수 있는 PR을 생성하고 이를 함께 토론하고 해결해 나아가는 과정은 너무 좋은 기억으로 남아있다.
 이러한 과정은 다른 기여자들에게 기여의 첫 발을 내딛게 해주며, 꾸준한 기여로 이어질 수 있는 기회를 제공한다.</p>
<h3 id="확인한-error-flow">확인한 Error Flow</h3>
<p>CWD에 따라 Array Contstructor 관련 에러가 발생하였다.
해당 공식문서를 찾아보니, @typescript-eslint에서는 이에 대한 에러를 반환하지 않지만, core rule에서는 에러를 반환하는 차이점을 알게 되었다.
 이를 기반으로 @typescript-eslint 플러그인이 로드되지 않아 fallback rule인 core rule을 사용하는 것 아닌가 하는 추론으로 부터 문제를 디깅했고, 관련된 에러나 히스토리 문서들을 추적하기 시작했다.</p>
<p>결론은 아래와 같았다.</p>
<blockquote>
<ol>
<li>root에 eslint가 설치되어있음.</li>
<li>package/sdk에 플러그인들이 설치되어있음.</li>
<li>pnpm i 이후, 하위 workspace에 eslint는 root에, plugin들은 하위 workspace에 존재.
root에 존재하는 eslint가 plugin을 로드하기 위해 resolver가 돌아가는 과정에서 root의 node_modules를 참조하지만, 이는 하위 workspace에만 존재하고 cwd가 workspace더라도 eslint는 root에 존재(아마 예전 resolver라 symlink관련 파싱 로직이 추가되지 않은 채 deprecated 된 것으로 추론)</li>
<li>로드 에러</li>
<li>(추론)@typescript-eslint가 정상적으로 override되지 않고 기본 ESLint rule만 사용(recommended의 core rule은 적용되는 것을 확인했기 때문에 최상단 또는 fallback rule로 recommended가 적용되었다고 생각합니다. 또는 IDE rule일 가능성도 존재)</li>
<li>환경별로 다를 ESLint rule이 적용됨.</li>
</ol>
</blockquote>
<p>이를 수정하는 PR을 날렸고, CI레벨에서 존재하는 ESLint warning 문제와 .npmrc를 사용함에도 engines prop에서 pnpm을 강제하지 않는 오류들을 함께 수정하였다.</p>
<h3 id="결과">결과</h3>
<ul>
<li>모든 환경에서 동일한 ESLint Rule을 보장한다.</li>
<li>모든 환경에서 공식문서에 기재된 pnpm 버전을 보장한다. 조건에 맞지 않을 경우 의존성 패키지 설치를 막고, 경고 메시지를 반환한다.</li>
<li>CWD에 따라 발생하던 차이가 있던 ESLint plugin로드 방식을 해결한다.</li>
<li>기존에 검증하지 않던 <code>.tsx</code> 파일들의 ESLInt 에러를 해결한다.</li>
</ul>
<hr>
<h3 id="1020-upstream-package-변경에-따른-deprecated-flag-변경">#1020 Upstream package 변경에 따른 deprecated flag 변경</h3>
<p><a href="https://github.com/yorkie-team/yorkie-js-sdk/issues/1019">issue#1019</a>
<a href="https://github.com/yorkie-team/yorkie-js-sdk/pull/1020">PR#1020</a></p>
<h3 id="개요">개요:</h3>
<p>통합 테스트 진행 시 1332개의 test가 실패하는 것을 확인하였다.
<img src="https://velog.velcdn.com/images/pengoose_dev/post/c141d7a5-ccc2-41bd-a253-5d3393c2c150/image.png" alt=""></p>
<p>문제점을 파악해보니, Upstream(yorkie) 패키지의 flag가 변경되었음을 확인하였다.</p>
<p>이를 해결해결하고, 다른 패키지들에도 동일한 문제점이 있다는 것을 발견하여 전체적으로 업데이트를 진행했다.</p>
<hr>
<h2 id="yorkie">yorkie</h2>
<h3 id="1389-attachdocument의-예외처리에서-schema-version을-반환하지-않음">#1389 AttachDocument의 예외처리에서 Schema version을 반환하지 않음</h3>
<p><a href="https://github.com/yorkie-team/yorkie/issues/1389#issuecomment-3076322401">issue#1389</a></p>
<h3 id="개요-1">개요:</h3>
<p>yorkie-js-sdk는 yorkie의 Spec을 그대로 추상화한다.</p>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/7de4632a-51e1-4dd1-ab4d-56c56f37ff0d/image.png" alt=""></p>
<p>js-sdk의 test spec에 따르면 AttachDocument의 예외처리에서 Schema의 version을 반환해야 하며, Upstream package의 추상화 코드를 보더라도 이에 대한 추상화가 일부 이루어져 있는 것을 알 수 있다.</p>
<p> 따라서, 추상화 누락이라고 판단하여 issue를 생성했다.</p>
<p><a href="https://github.com/yorkie-team/yorkie-js-sdk/pull/1024">PR#1024</a>이 이를 closed하였으나, 회귀 테스트에서 버전을 검증하지 아니하고, equal에서 include 메서드로 검증의 강도를 약하게 만든 변경사항이었다. 즉, 문제의 본질은 해결되지 않았다고 생각해, 관련 이슈를 논의하였다.</p>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/eabdac00-0ccb-43c0-b3f0-6e6c2b0a83dc/image.png" alt=""></p>
<p>이에 대한 답변을 받을 수 있었다.</p>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/cb112df8-c173-4151-b284-0a5ae826b51a/image.png" alt=""></p>
<p>물론, Issue를 다시 생성하고, 이에 대한 추상화를 진행하는 것도 하나의 방법이 되겠지만 현재 단계에서 굳이 추상화 할 필요가 없는 기능이라는 것에 동의하였다. 추후에 이에 대한 업데이트가 이루어지면 js-sdk에서 version에 대한 테스트를 추가하고자 한다.</p>
<hr>
<h2 id="yorkie-teamgithubio">yorkie-team.github.io</h2>
<h3 id="222-공식문서-cli-업데이트">#222 공식문서 CLI 업데이트</h3>
<p><a href="https://github.com/yorkie-team/yorkie-team.github.io/issues/222">issue#222</a>
<a href="https://github.com/yorkie-team/yorkie-team.github.io/pull/223">PR#223</a></p>
<h3 id="개요-2">개요:</h3>
<p>변경되어 사라진 CLI flag를 여전히 문서로 제공하고 있었다.
이를 수정한 PR</p>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/1a013cf4-fdd1-4c35-8ac2-bc7b78740109/image.png" alt=""></p>
<hr>
<h2 id="dashboard">dashboard</h2>
<h3 id="229-upstream-package-변경에-따른-deprecated-flag-변경">#229 Upstream package 변경에 따른 deprecated flag 변경</h3>
<p><a href="https://github.com/yorkie-team/dashboard/issues/229">issue#229</a>
<a href="https://github.com/yorkie-team/dashboard/pull/233">PR#233</a></p>
<h3 id="개요-3">개요:</h3>
<p>Issue 생성 후, 다른 기여자를 위해 남겨둔 Issue.</p>
<p>sigmaith님이 PR을 제출하여 Issue를 close 해주셨다.</p>
<hr>
<h2 id="codepair">codepair</h2>
<h3 id="496-docker-compose-top-level-version-삭제-및-docker-image-최신화">#496 docker-compose top-level version 삭제 및 Docker image 최신화</h3>
<p><a href="https://github.com/yorkie-team/codepair/issues/495">issue#495</a>
<a href="https://github.com/yorkie-team/codepair/pull/496">PR#496</a></p>
<h3 id="개요-4">개요:</h3>
<ul>
<li>docker image 버전 최신화</li>
<li>Upstream CLI 변경</li>
<li>docker-compose의 deprecated된 top-level version 수정</li>
</ul>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/4b53b2ad-c5f9-4295-bbf4-5a250d35d854/image.png" alt=""></p>
<hr>
<p>다음 달부터는 CodePair의 Feature와 전체적인 회귀 테스트 보강에 신경써볼까 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Operation based CRDT 기초]]></title>
            <link>https://velog.io/@pengoose_dev/Operation-based-CRDT-%EA%B8%B0%EB%B3%B8-%EA%B0%9C%EB%85%90</link>
            <guid>https://velog.io/@pengoose_dev/Operation-based-CRDT-%EA%B8%B0%EB%B3%B8-%EA%B0%9C%EB%85%90</guid>
            <pubDate>Wed, 09 Jul 2025 03:20:20 GMT</pubDate>
            <description><![CDATA[<ul>
<li>CRDT는 interface일 뿐, 추상화를 하는 방식은 다양함.
<a href="https://www.bartoszsypytkowski.com/operation-based-crdts-registers-and-sets/">https://www.bartoszsypytkowski.com/operation-based-crdts-registers-and-sets/</a></li>
</ul>
<hr>
<h2 id="strong-eventual-consistency">Strong eventual consistency</h2>
<ul>
<li>동일한 <code>update operation set</code>을 가진 노드(ex: client)들은 <strong>update Operation의 순서에 상관 없이</strong> 최종적으로 같은 상태로 수렴.</li>
<li>updated operation 발생시 replica에게 state 전파</li>
</ul>
<p>따라서, 아래의 명제들로 확장할 수 있다.</p>
<ol>
<li><code>교환법칙</code>과 <code>결합 법칙</code>이 성립(Operation 순서에 상관이 없기 때문)</li>
<li>Duplicated operation 허용한다면 멱등성(Idempotent) 성립X. (무효화한다면 멱등성 성립)</li>
</ol>
<hr>
<h4 id="a-교환법칙-결합법칙이-성립하는-이유">+a 교환법칙, 결합법칙이 성립하는 이유</h4>
<ul>
<li>CRDT는 기본적으로 라티스의 <strong>join(semi-lattice)</strong> 구조를 따른다.</li>
<li>Counter 기반 설계의 경우, 각 replica의 카운터를 더하는 덧셈 연산을 join으로 사용하기 때문이다.</li>
</ul>
<p>이는 interface일 뿐, 결국</p>
<ol>
<li>Data type 설계</li>
<li>Operation merge 전략
에서 교환법칙, 결합법칙, 멱등성의 조건을 만족하도록 추상화 하는 것이 본질로 보인다.</li>
</ol>
<hr>
<h3 id="p-이벤트의-수신-순서에-따라-교환-법칙이-깨지는-경우">P: 이벤트의 수신 순서에 따라 교환 법칙이 깨지는 경우</h3>
<p>하지만 수신받은 Operation의 &quot;순서&quot;가 교환법칙에 위배되는 경우가 있다. 이벤트의 인과관계나 context가 존재하는 경우가 그것이다.
즉, <strong>단순 commutativity</strong>만으론 <code>동시 삽입이 어느 순서로 반영돼야 할지</code> 결정할 수 없다.</p>
<p>이는 Operation Event 안에, Vector Clock을 추가하여 해결할 수 있다.</p>
<p>예를 들어, 노드가 수신한 update Operation stack이 <code>[&lt;1,0,0,2&gt;, &lt;1,0,0,0&gt;, &lt;1,0,0,1&gt;]</code>와 같은 경우, 수신 순서와 무관하게  <code>[&lt;1,0,0,0&gt;, &lt;1,0,0,1&gt;, &lt;1,0,0,2&gt;]</code> 순서로 정렬할 수 있음  </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[AST walker with estree, acorn to detect local shadowing ]]></title>
            <link>https://velog.io/@pengoose_dev/AST-walker-with-estree-acorn-to-detect-local-shadowing</link>
            <guid>https://velog.io/@pengoose_dev/AST-walker-with-estree-acorn-to-detect-local-shadowing</guid>
            <pubDate>Tue, 18 Mar 2025 06:33:50 GMT</pubDate>
            <description><![CDATA[<p><a href="https://github.com/vitest-dev/vitest/pull/7562">Draft PR</a></p>
<p><a href="https://wikidocs.net/156284">https://wikidocs.net/156284</a>
<a href="https://github.com/jamiebuilds/babel-handbook/blob/master/translations/ko/plugin-handbook.md#toc-asts">https://github.com/jamiebuilds/babel-handbook/blob/master/translations/ko/plugin-handbook.md#toc-asts</a>
<a href="https://medium.com/basecs/leveling-up-ones-parsing-game-with-asts-d7a6fc2400ff">https://medium.com/basecs/leveling-up-ones-parsing-game-with-asts-d7a6fc2400ff</a>
<a href="http://astexplorer.net/">http://astexplorer.net/</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[# 11. [Vitest] fix: support custom toString method in %s format
]]></title>
            <link>https://velog.io/@pengoose_dev/11.-Vitest-fix-support-custom-toString-method-in-s-format</link>
            <guid>https://velog.io/@pengoose_dev/11.-Vitest-fix-support-custom-toString-method-in-s-format</guid>
            <pubDate>Tue, 11 Mar 2025 16:43:25 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/40eefe83-2ef8-42b4-9975-5c5ffa04f1d5/image.png" alt=""></p>
<p><a href="https://github.com/vitest-dev/vitest/pull/7637">PR</a>
<a href="https://github.com/vitest-dev/vitest/issues/7634">Issue</a></p>
<h2 id="1-issue">1. Issue</h2>
<p>vitest의 format 유틸이 %s를 처리하는 방식이 <a href="https://nodejs.org/api/util.html#utilformatformat-args">NodeJS Spec</a>이랑 다름.
(커스텀 toString 메서드 있을 때, node inspect말고 커스텀 toString 사용해야함.)</p>
<blockquote>
<p>%s: String will be used to convert all values except BigInt, Object and -0.
BigInt values will be represented with an n and Objects
<code>that have no user defined toString function are inspected using util.inspect()</code>
with options { depth: 0, colors: false, compact: 3 }.</p>
</blockquote>
<hr>
<h2 id="2-context-파악">2. Context 파악</h2>
<pre><code class="language-ts">switch (x) {
  case &#39;%s&#39;: {
    const value = args[i++]
    if (typeof value === &#39;bigint&#39;) {
      return `${value.toString()}n`
    }
    if (typeof value === &#39;number&#39; &amp;&amp; value === 0 &amp;&amp; 1 / value &lt; 0) {
      return &#39;-0&#39;
    }
    if (typeof value === &#39;object&#39; &amp;&amp; value !== null) {
      // 🎯 inspect만 사용. toString 검증 로직 추가해야함.
      return inspect(value, { depth: 0, colors: false })
    }
    return String(value)</code></pre>
<hr>
<h2 id="3-회귀-테스트">3. 회귀 테스트</h2>
<p>기존 테스트에 추가해주기</p>
<pre><code class="language-ts">import util from &#39;node:util&#39;
import { format } from &#39;@vitest/utils&#39;
import { describe, expect, test } from &#39;vitest&#39;
describe(&#39;format&#39;, () =&gt; {
  const obj = {} as any
  obj.obj = obj
  test.each([
    // ...cases
    [
      &#39;%s&#39;,
      new (class {
        constructor(public value: string) {}
        toString() {
          return this.value
        }
      })(&#39;string value&#39;),
    ],
    // ...cases
  ])(&#39;format(%s)&#39;, (formatString, ...args) =&gt; {
    expect(format(formatString, ...args), `failed ${formatString}`).toBe(util.format(formatString, ...args))
  })</code></pre>
<hr>
<h2 id="4-해결">4. 해결</h2>
<p>새로 정의된 toString 메서드가 있는지 확인하고 분기처리하면 끝난다.</p>
<pre><code class="language-ts">switch (x) {
  case &#39;%s&#39;: {
    const value = args[i++]
    if (typeof value === &#39;bigint&#39;) {
      return `${value.toString()}n`
    }
    if (typeof value === &#39;number&#39; &amp;&amp; value === 0 &amp;&amp; 1 / value &lt; 0) {
      return &#39;-0&#39;
    }
    if (typeof value === &#39;object&#39; &amp;&amp; value !== null) {
      // 🎯 함수면서 &amp;&amp; toString이 오버라이딩 되었니? &gt; 그렇다면 toString 호출
      if (typeof value.toString === &#39;function&#39; &amp;&amp; value.toString !== Object.prototype.toString) {
        return value.toString()
      }
      return inspect(value, { depth: 0, colors: false })
    }
    return String(value)</code></pre>
<hr>
<h2 id="merged">merged</h2>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/f526850e-ba72-4f70-8e7c-c82eb324a720/image.png" alt=""></p>
<p>별 다른 피드백 없이 merged</p>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/98f911fc-47db-4aba-8995-86cb765808b6/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[# 10. [Playwright] feat(webServer): support name option for custom web server log prefixes]]></title>
            <link>https://velog.io/@pengoose_dev/10.-Playwright-featwebServer-support-name-option-for-custom-web-server-log-prefixes</link>
            <guid>https://velog.io/@pengoose_dev/10.-Playwright-featwebServer-support-name-option-for-custom-web-server-log-prefixes</guid>
            <pubDate>Tue, 11 Mar 2025 04:17:55 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/4518e732-3449-4a46-a908-fa6656d1dc21/image.png" alt=""></p>
<p><a href="https://github.com/microsoft/playwright/pull/35105">PR</a>
<a href="https://github.com/microsoft/playwright/issues/35064">Issue</a></p>
<h2 id="1-issue">1. Issue</h2>
<p>현재 Playwright에서 여러개의 웹서버를 띄울 때, 각 서버 로그의 Prefix가 <code>[WebServer]</code>로 고정되어있다.
 각 서버별 로그 파악이 용이하도록 <code>defineConfig</code> 함수에 커스텀 prefix 기능요청이 들어왔다.</p>
<hr>
<h2 id="2-context-파악">2. Context 파악</h2>
<pre><code class="language-ts">
export class WebServerPlugin implements TestRunnerPlugin {
  // ...codes
  private async _startProcess(): Promise&lt;void&gt; {
    // ...codes
    launchedProcess.stderr!.on(&#39;data&#39;, data =&gt; {
      if (debugWebServer.enabled || (this._options.stderr === &#39;pipe&#39; || !this._options.stderr))
        this._reporter!.onStdErr?.(prefixOutputLines(data.toString())); // ⛳️
    });
    launchedProcess.stdout!.on(&#39;data&#39;, data =&gt; {
      if (debugWebServer.enabled || this._options.stdout === &#39;pipe&#39;)
        this._reporter!.onStdOut?.(prefixOutputLines(data.toString())); // ⛳️
    });
  }</code></pre>
<pre><code class="language-ts">
function prefixOutputLines(output: string): string {
  const lastIsNewLine = output[output.length - 1] === &#39;\n&#39;;
  let lines = output.split(&#39;\n&#39;);
  if (lastIsNewLine)
    lines.pop();
  lines = lines.map(line =&gt; colors.dim(&#39;[WebServer] &#39;) + line); // ⛳️ 1. 호출하면 WebServer Prefix가 하드코딩 되어있음.
  if (lastIsNewLine)
    lines.push(&#39;&#39;);
  return lines.join(&#39;\n&#39;);
}
</code></pre>
<p>WebServerPlugin 객체의 <code>this._option</code>에 optional로 name 프로퍼티를 추가해준 뒤, defaultValue를 기존 WebServer로 설정한채 상위에서 name을 매개변수로 주입해주면 쉽게 구현이 가능하다.</p>
<hr>
<h2 id="3-회귀-테스트">3. 회귀 테스트</h2>
<p>기존 테스트코드 컨벤션에 맞게 테스트를 우선 추가해준다.</p>
<pre><code class="language-ts">test.describe(&#39;name option&#39;, () =&gt; {
  test(&#39;should use custom prefix&#39;, async ({ runInlineTest }, { workerIndex }) =&gt; {
    const port = workerIndex * 2 + 10500;
    const name1 = &#39;CustomName1&#39;;
    const name2 = &#39;CustomName2&#39;;
    const defaultPrefix = &#39;WebServer&#39;;
    const result = await runInlineTest({
      &#39;test.spec.ts&#39;: `
      import { test, expect } from &#39;@playwright/test&#39;;
      test(&#39;pass&#39;, async ({}) =&gt; {});
    `,
      &#39;playwright.config.ts&#39;: `
      module.exports = {
        webServer: [
          {
            command: &#39;node ${JSON.stringify(SIMPLE_SERVER_PATH)} ${port}&#39;,
            port: ${port},
            name: &#39;${name1}&#39;,
          },
          {
            command: &#39;node ${JSON.stringify(SIMPLE_SERVER_PATH)} ${port + 1}&#39;,
            port: ${port + 1},
            name: &#39;${name2}&#39;,
          }
        ],
      };
    `,
    }, undefined);
    expect(result.exitCode).toBe(0);
    expect(result.output).toContain(`[${name1}]`);
    expect(result.output).toContain(`[${name2}]`);
    expect(result.output).not.toContain(`[${defaultPrefix}]`);
  });

  test(&#39;should use default prefix when name option is not set&#39;, async ({ runInlineTest }, { workerIndex }) =&gt; {
    const port = workerIndex * 2 + 10500;
    const defaultPrefix = &#39;WebServer&#39;;
    const result = await runInlineTest({
      &#39;test.spec.ts&#39;: `
      import { test, expect } from &#39;@playwright/test&#39;;
      test(&#39;pass&#39;, async ({}) =&gt; {});
    `,
      &#39;playwright.config.ts&#39;: `
      module.exports = {
        webServer: {
          command: &#39;node ${JSON.stringify(SIMPLE_SERVER_PATH)} ${port}&#39;,
          port: ${port},
        },
      };
    `,
    }, undefined);
    expect(result.exitCode).toBe(0);
    expect(result.output).toContain(`[${defaultPrefix}]`);
  });
});</code></pre>
<h2 id="4-해결">4. 해결</h2>
<pre><code class="language-ts">export type WebServerPluginOptions = {
  // ...props
  name?: string;
};</code></pre>
<p>필드 추가하고</p>
<pre><code class="language-ts">// ⛳️ 1. DefaultValue는 기존 Prefix(WebServer) 사용
function prefixOutputLines(output: string, prefixName: string = &#39;WebServer&#39;): string {
  const lastIsNewLine = output[output.length - 1] === &#39;\n&#39;;
  let lines = output.split(&#39;\n&#39;);
  if (lastIsNewLine)
    lines.pop();
  lines = lines.map(line =&gt; colors.dim(`[${prefixName}] `) + line); // ⛳️
  if (lastIsNewLine)
    lines.push(&#39;&#39;);
  return lines.join(&#39;\n&#39;);
}</code></pre>
<p>동적 할당하고</p>
<pre><code class="language-ts">launchedProcess.stderr!.on(&#39;data&#39;, data =&gt; {
  if (debugWebServer.enabled || (this._options.stderr === &#39;pipe&#39; || !this._options.stderr))
    this._reporter!.onStdErr?.(prefixOutputLines(data.toString(), this._options.name));
});
launchedProcess.stdout!.on(&#39;data&#39;, data =&gt; {
  if (debugWebServer.enabled || this._options.stdout === &#39;pipe&#39;)
    this._reporter!.onStdOut?.(prefixOutputLines(data.toString(), this._options.name));
});</code></pre>
<p>주입</p>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/b6a39131-148d-48a3-9096-a53c1095e194/image.png" alt=""></p>
<p>이후 문서 작성 및 빌드로 타입 생성해주고 완료</p>
<hr>
<h2 id="5-merged">5. Merged</h2>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/d5388b99-62a4-4499-963e-c93db118216d/image.png" alt=""></p>
<hr>
<h2 id="동일한-feature">동일한 Feature</h2>
<p>해당 피쳐가 merged되고 약 2달 뒤, 핑이 날아왔다.
<img src="https://velog.velcdn.com/images/pengoose_dev/post/d0adf810-0e4a-4b0b-bd8d-251a24afda62/image.png" alt=""></p>
<p>컨텍스트를 살펴보니 1년 전에 이미 동일한 Feature 요구가 있었고, 그 당시엔 p3로 남았던 건이었다.</p>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/06d46076-99ec-4d13-b00f-0a6ba9c527e4/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/8e07100c-5558-4d28-a4d0-99fdaf076951/image.png" alt=""></p>
<p>꾸준히 관심을 가지고 피쳐 업데이트를 살펴보셨나보다.
 가끔 issue 생성자 분들이 이모지를 남겨주시는데, 감사인사를 남겨주셔서 필자가 더 감사할 따름이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[# 9. [Vitest] fix(expect): Correct generic MatchersObject this type in expect.extend]]></title>
            <link>https://velog.io/@pengoose_dev/8.-Vitest-fixexpect-Correct-generic-MatchersObject-this-type-in-expect.extend</link>
            <guid>https://velog.io/@pengoose_dev/8.-Vitest-fixexpect-Correct-generic-MatchersObject-this-type-in-expect.extend</guid>
            <pubDate>Thu, 20 Feb 2025 04:21:42 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/6e3740e5-f7e3-4d29-ace5-5f0f74f896ae/image.png" alt=""></p>
<p><a href="https://github.com/vitest-dev/vitest/pull/7526">PR</a>
<a href="https://github.com/vitest-dev/vitest/issues/6967">Issue</a></p>
<h2 id="1-issue">1. Issue</h2>
<p>expect.extend의 커스텀 매처에 제네릭 타입을 주입 할 경우, 부모 객체의 커스텀매처 타입이 추론되지 않는 버그.</p>
<h2 id="2-context-파악">2. Context 파악</h2>
<pre><code class="language-ts">  expect.extend({
    toBeLike&lt;T&gt;(received: unknown, expected: T) {
      const { isNot, utils } = this
      return {
        pass: received == expected,
        message: () =&gt; isNot
        // ⛳️ 부모 객체의 utils의 타입이 유실됨.
          ? `Expected ${utils.stringify(received)} to not be like ${utils.stringify(expected)}`
          : `Expected ${utils.stringify(received)} to be like ${utils.stringify(expected)}`,
        actual: received,
        expected
      }
    },
  })</code></pre>
<p>기존 타입은 아래와 같다.</p>
<pre><code class="language-ts">export interface MatcherState {
  customTesters: Array&lt;Tester&gt;
  assertionCalls: number
  currentTestName?: string
  dontThrow?: () =&gt; void
  error?: Error
  equals: (
    a: unknown,
    b: unknown,
    customTesters?: Array&lt;Tester&gt;,
    strictCheck?: boolean
  ) =&gt; boolean
  expand?: boolean
  expectedAssertionsNumber?: number | null
  expectedAssertionsNumberErrorGen?: (() =&gt; Error) | null
  isExpectingAssertions?: boolean
  isExpectingAssertionsError?: Error | null
  isNot: boolean
  // environment: VitestEnvironment
  promise: string
  // snapshotState: SnapshotState
  suppressedErrors: Array&lt;Error&gt;
  testPath?: string
  utils: ReturnType&lt;typeof getMatcherUtils&gt; &amp; {
    diff: typeof diff
    stringify: typeof stringify
    iterableEquality: Tester
    subsetEquality: Tester
  }
  soft?: boolean
  poll?: boolean
}

export interface SyncExpectationResult {
  pass: boolean
  message: () =&gt; string
  actual?: any
  expected?: any
}

export type AsyncExpectationResult = Promise&lt;SyncExpectationResult&gt;

export type ExpectationResult = SyncExpectationResult | AsyncExpectationResult

// ⛳️ 3. this에 MatcherState를 주입한다 &lt;&lt; 문제점
export interface RawMatcherFn&lt;T extends MatcherState = MatcherState&gt; {
  (this: T, received: any, ...expected: Array&lt;any&gt;): ExpectationResult
}

export type MatchersObject&lt;T extends MatcherState = MatcherState&gt; = Record&lt;
  string,
  RawMatcherFn&lt;T&gt; // ⛳️ 2.RawMatcherFn&lt;T&gt;를 상속받는다.
&gt;

export interface ExpectStatic
  extends Chai.ExpectStatic,
  AsymmetricMatchersContaining {
  &lt;T&gt;(actual: T, message?: string): Assertion&lt;T&gt;
  extend: (expects: MatchersObject) =&gt; void // ⛳️ 1. extend의 MatchersObject는
  anything: () =&gt; any
  any: (constructor: unknown) =&gt; any
  getState: () =&gt; MatcherState
  setState: (state: Partial&lt;MatcherState&gt;) =&gt; void
  not: AsymmetricMatchersContaining
}</code></pre>
<blockquote>
<p>요약: <strong><code>메서드</code></strong>가 아닌 <strong><code>함수</code></strong>로 인식된다. this에 대한 타입 정보가 명확히 주어지지 않아 제네릭타입을 강제로 주입하더라도, 무시되기(적절한 추론이 이뤄지지 않기) 때문이다.</p>
</blockquote>
<p> 위 방식에서는 객체 리터럴로 작성된 함수가 단순 함수로 인식된다. 따라서, 문맥적 this 타입 정보가 주입되지 않는다. 함수 시그니처에 선언된 this 타입 정보(<code>&lt;T extends MatcherState = MatcherState&gt;</code>)가 객체 리터럴의 문맥적 타입으로 전달되지 않기 때문이다.</p>
<p>따라서, 실제 this는 말 그대로 MatchersObject<MatcherState>로 추론된다. (아마 함수 오버로딩으로 모듈을 많이 만들어본 경험이 있다면, 함수 시그니쳐를 매핑할 때 비슷한 이슈를 많이 겪어보았을 것이다) </p>
<p>즉, typescript가 this를 적절히 추론할 수 있도록 플래그를 추가해주면 된다.</p>
<h2 id="3-회귀-테스트">3. 회귀 테스트</h2>
<p>  <img src="https://velog.velcdn.com/images/pengoose_dev/post/fa420a49-daab-4c10-8836-ffc4d3760ab7/image.png" alt=""></p>
<p>기존 테스트코드를 뒤져봤지만, 해당 케이스에 적용하기 적절한 타입 추론 테스트가 없었다. 리뷰 과정에서 아래의 코드로 퉁치는 것으로 마무리했다.</p>
<pre><code class="language-ts">toBeTestedMatcherContext&lt;T&gt;(received: unknown, expected: T) {
  if (typeof this.utils?.stringify !== &#39;function&#39;) {
    throw new TypeError(&#39;this.utils.stringify is not available.&#39;)
  }
  return {
    pass: received === expected,
    message: () =&gt; &#39;toBeTestedMatcherContext&#39;,
  }
},</code></pre>
<h2 id="4-해결">4. 해결</h2>
<p>위에서 언급한대로 this가 적절히 추론되도록 플래그만 추가해주면 된다.
 마침, 일전에 상태관리 코어모듈의 this타입(도메인 아톰)에 대한 타입추론 이슈를 겪어본 적이 있었어서 그때 사용했던 ThisType 유틸 함수를 사용해서 쉽게 해결할 수 있었다.</p>
<p><a href="https://www.typescriptlang.org/ko/docs/handbook/utility-types.html#thistypetype"><code>[Typescript Docs] ThisType&lt;T&gt;</code></a></p>
<pre><code class="language-ts">export type MatchersObject&lt;T extends MatcherState = MatcherState&gt; = Record&lt;
  string,
  RawMatcherFn&lt;T&gt;
&gt; &amp; ThisType&lt;T&gt; // ⛳️ ThisType 플래그 추가</code></pre>
<hr>
<h2 id="5-merged">5. Merged</h2>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/77c7b1e0-06e1-4555-a966-6f218967d95b/image.png" alt=""></p>
<p>vitest에 다시 기여를 진행한 건 거의 9개월만이다. PR에 대한 피드백 주기 및 방향성 제안 등에 모종의 불편함을 많이 느꼈었기 때문이다.</p>
<p> playwright 위주로 기여를 진행해왔지만, 이제 playwright 코어모듈의 expect나 test runner의 피쳐 및 버그를 거의 다 해결한 상태이기에 다시 vitest를 기웃거리게 되었다.</p>
<p> 9개월 전보다 실력도 많이 올라왔고, vitest의 코드 리뷰 방식도 조금 바뀐 것 같아서 (당시엔 기여자가 많이 적어서 그랬나보다) 다시 vitest에 조금씩 기여를 진행하고자 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Tree to SplayTree]]></title>
            <link>https://velog.io/@pengoose_dev/Tree-to-SplayTree</link>
            <guid>https://velog.io/@pengoose_dev/Tree-to-SplayTree</guid>
            <pubDate>Mon, 17 Feb 2025 03:31:07 GMT</pubDate>
            <description><![CDATA[<p> 기여하고 싶은 오픈소스가 있는데, 코드베이스가 말 그대로 어려웠다. (동시 편집을 위한 CRDT—conflict free replicated data type—가 splayTree기반으로 작성되어있음)
따라서, 이를 학습하고자 한다.</p>
<p> SplayTree의 자료구조 특성상 학습 입문자용 가이드가 전혀 없었다. 혹시, 필요한 사람들이 있을까하여 하나 남겨두고자 한다.</p>
<hr>
<h1 id="1-트리tree">1. 트리(Tree)</h1>
<p>트리의 구성요소는 아래와 같다.</p>
<blockquote>
</blockquote>
<ul>
<li>node:
트리의 기본단위. <code>데이터</code>와 자식 노드의 <code>포인터</code>를 가짐.</li>
<li>root:
트리 최상단 노드</li>
<li>leaf:
자식이 없는 노드<blockquote>
</blockquote>
각 노드는 <strong>0개 이상의 자식</strong>을 가지며, 자식은 <strong>하나의 부모노드</strong>를 가짐.</li>
</ul>
<pre><code class="language-js">class Node {
  children = [];

  constructor(value) {
    this.value = value;
  }
}</code></pre>
<pre><code class="language-js">import { Node } from &#39;./tree/node&#39;;

const root = new Node(1); // root node
// root node의 자식 노드(2번째 노드) 추가
root.children.push(new Node(2));
// root node의 자식 노드(3번째 노드) 추가
root.children.push(new Node(3));
// root node의 자식 노드(2번째 노드)의 자식 노드(4번째 노드) 추가
root.children[0].children.push(new Node(4));


console.log(root);
/**
 *  Node {
 *    value: 1,
 *    children: [
 *      Node {
 *        value: 2,
 *        children: [ Node { value: 4, children: [] }
 *     },
 *     Node { value: 3, children: [] }
 *   ]
 * }
 */</code></pre>
<ul>
<li>노드는 여러 자식을 가질 수 있음.</li>
<li>다만, 보통 문제에선 이진트리를 많이 사용.</li>
</ul>
<hr>
<h1 id="2-이진트리binary-tree">2. 이진트리(Binary Tree)</h1>
<ul>
<li>각 노드가 최대 2개의 자식(right, left)을 갖는 트리.</li>
</ul>
<hr>
<h1 id="3-이진-탐색-트리binary-search-tree">3. 이진 탐색 트리(Binary Search Tree)</h1>
<p>거기서 검색을 빠르게 함. 근데 빠르게 검색하기 위해서 조건을 좀 붙임.</p>
<ul>
<li>각각의 노드는 <strong>왼쪽 서브트리의 모든 값은 노드의 값보다 작고</strong>, <strong>오른쪽 서브트리의 모든 값은 노드의 값보다 큼</strong>.</li>
</ul>
<p>이러면 검색뿐 아니라 삽입, 삭제 연산을 O(logN)으로 땡길 수 있음.</p>
<pre><code class="language-js">class BSTNode {
  left = null; // 왼쪽 자식
  right = null; // 오른쪽 자식
  parent = null; // optional. 다만, 추후 splayTree 개념 확장을 위해 추가

  constructor(value) {
    this.value = value;
  }
}

class BinarySearchTree {
  root = null;

  // 삽입 O(logN)
  insert(value) {
    const newNode = new BSTNode(value);
    if (!this.root) {
      this.root = newNode;
      return newNode;
    }
    let cur = this.root;
    let parent = null;
    while (cur) {
      parent = cur;
      if (value &lt; cur.value) cur = cur.left;
      else cur = cur.right;
    }
    newNode.parent = parent;
    if (value &lt; parent.value) parent.left = newNode;
    else parent.right = newNode;
    return newNode;
  }

  // 탐색 O(logN)
  find(value) {
    let cur = this.root;
    while (cur) {
      if (cur.value === value) return cur;
      else if (value &lt; cur.value) cur = cur.left;
      else cur = cur.right;
    }
    return null;
  }
}</code></pre>
<pre><code class="language-js">const bst = new BinarySearchTree();
bst.insert(10);
bst.insert(5);
bst.insert(15);
console.log(bst.find(15));
/**
 * BSTNode {
 *  value: 15,
 *  left: null,
 *  right: null,
 *  parent: BSTNode {
 *   value: 10,
 *   left: BSTNode { value: 5, left: null, right: null, parent: [BSTNode] },
 *   right: BSTNode { value: 15, left: null, right: null, parent: [BSTNode] }
 *  }
 * }
 */</code></pre>
<ul>
<li>BST는 정렬된 순서로 데이터 저장. 이진탐색과 비슷하게 동작</li>
<li>부모 포인터가 왜 있는가? &lt; SplayTree와 같은 자기 조정 트리에서 써야함.</li>
</ul>
<hr>
<h1 id="4-근데-막-회전함">4. 근데 막 회전함</h1>
<p>회전(Rotation)은 <code>두 노드 간의 관계를 재조정</code>하는 연산임</p>
<ul>
<li>좌측 회전: 오른쪽 자식을 루트로 끌어올림.</li>
<li>우측 회전: 왼쪽 자식을 루트로 끌어올림.</li>
</ul>
<p>근데 얘 왜 돎?:</p>
<ul>
<li>자주 접근하는 노드가 루트에 가깝게 조정되서, 접근시간 단축됨</li>
</ul>
<pre><code class="language-js">const rotate = (x, dir) =&gt; {
  const p = x.parent;
  if (!p) return; // root node인 경우 회전 불가
  if (dir === &#39;right&#39;) {
    p.left = x.right;
    if (x.right) x.right.parent = p;
    x.right = p;
  } else {
    p.right = x.left;
    if (x.left) x.left.parent = p;
    x.left = p;
  }
  x.parent = p.parent;
  if (p.parent) {
    if (p.parent.left === p) p.parent.left = x;
    else p.parent.right = x;
  }
  p.parent = x;
};</code></pre>
<p>그냥 차이점 개행으로 분리하자면</p>
<pre><code class="language-js">
const Rotate = {
  right(x) {
    const p = x.parent;
    if (!p) return; // root node인 경우 회전 불가

    p.left = x.right;
    if (x.right) x.right.parent = p;
    x.right = p;

    x.parent = p.parent;
    if (p.parent) {
      if (p.parent.left === p) p.parent.left = x;
      else p.parent.right = x;
    }
    p.parent = x;
  },

  left(x) {
    const p = x.parent;
    if (!p) return; // root node인 경우 회전 불가

    p.right = x.left;
    if (x.left) x.left.parent = p;
    x.left = p;

    x.parent = p.parent;
    if (p.parent) {
      if (p.parent.left === p) p.parent.left = x;
      else p.parent.right = x;
    }
    p.parent = x;
  },
};</code></pre>
<hr>
<h1 id="splay-tree">Splay Tree</h1>
<p>이 친구는 <code>특정 노드에 접근할 때</code>마다 해당 노드를 루트로 끌어올림(<strong>자기 조정 트리</strong>)</p>
<h3 id="splay-연산">splay 연산:</h3>
<p>흥이 넘치는 연산이 3개 있음.</p>
<blockquote>
<ul>
<li>zig:
노드 부모가 <code>루트</code>일 때, 단순 회전.</li>
</ul>
</blockquote>
<ul>
<li>zig-zig:
노드와 부모가 <code>같은 방향</code>일 때, <strong>부모가 먼저 회전</strong> &gt; 이후 노드 회전.</li>
<li>zig-zag:
노드와 부모가 <code>반대 방향</code>일 때, <strong>회전 2배 이벤트</strong> (2번 연속 회전)</li>
</ul>
<hr>
<h3 id="impl">Impl</h3>
<pre><code class="language-js">class SplayNode {
  left = null;
  right = null;
  parent = null;

  constructor(value) {
    this.value = value;
  }
}</code></pre>
<p>노드는 아까 BST랑 동일.</p>
<pre><code class="language-js">class SplayTree {
  root = null;

  rotate(x) {
    const p = x.parent;
    if (!p) return; // 루트 노드는 회전할 수 없음
    const g = p.parent;
    if (p.left === x) {
      // 우측 회전
      p.left = x.right;
      if (x.right) x.right.parent = p;
      x.right = p;
    } else {
      // 좌측 회전
      p.right = x.left;
      if (x.left) x.left.parent = p;
      x.left = p;
    }
    p.parent = x;
    x.parent = g;
    if (g) {
      if (g.left === p) g.left = x;
      else g.right = x;
    } else {
      this.root = x;
    }
  }

  splay(x) {
    if (!x) return; // 노드가 없으면 종료
    while (x.parent) {
      const p = x.parent;
      const g = p.parent;

      const isZig = !g;
      const isZigZig =
        (g &amp;&amp; g.left === p &amp;&amp; p.left === x) || (g.right === p &amp;&amp; p.right === x);

      // 3가지 회전 케이스
      if (isZig) {
        // zig
        this.rotate(x);
      } else if (isZigZig) {
        // zig-zig
        this.rotate(p);
        this.rotate(x);
      } else {
        // zig-zag
        this.rotate(x);
        this.rotate(x);
      }
    }
    this.root = x;
  }

  // 삽입: BST 삽입 후, 해당 노드 루트로 splay
  insert(value) {
    const newNode = new SplayNode(value);
    if (!this.root) {
      this.root = newNode;
      return newNode;
    }
    let cur = this.root;
    let parent = null;
    while (cur) {
      parent = cur;
      if (value &lt; cur.value) cur = cur.left;
      else cur = cur.right;
    }
    newNode.parent = parent;
    if (value &lt; parent.value) parent.left = newNode;
    else parent.right = newNode;
    this.splay(newNode);
    return newNode;
  }

  // 검색: value 찾고, 해당 노드 루트로 splay
  find(value) {
    let cur = this.root;
    while (cur) {
      if (cur.value === value) {
        this.splay(cur);
        return cur;
      } else if (value &lt; cur.value) {
        cur = cur.left;
      } else {
        cur = cur.right;
      }
    }
    return null;
  }
}</code></pre>
<hr>
<h1 id="splaytree-문제들">SplayTree 문제들</h1>
<p><a href="https://www.acmicpc.net/problemset?sort=ac_desc&amp;algo=69">&gt; 배웠으면 헤딩</a></p>
<p><a href="https://cubelover.tistory.com/10">C++로 보기 좋은 자료</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[# 8. [Microsoft / playwright] feat: expect(locator).toHaveAccessibleErrorMessage]]></title>
            <link>https://velog.io/@pengoose_dev/8.-Microsoft-playwright-feat-expectlocator.toHaveAccessibleErrorMessage</link>
            <guid>https://velog.io/@pengoose_dev/8.-Microsoft-playwright-feat-expectlocator.toHaveAccessibleErrorMessage</guid>
            <pubDate>Tue, 31 Dec 2024 06:19:45 GMT</pubDate>
            <description><![CDATA[<h2 id="1-issue">1. Issue</h2>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/06c5029b-3e56-44fb-8664-593a8e444468/image.png" alt=""></p>
<p><a href="https://github.com/microsoft/playwright/pull/33904">PR</a>
<a href="https://github.com/microsoft/playwright/issues/31249">issue#31249</a></p>
<p>Input에 대해 input validity기반 a11y 요소 기반 테스트 메서드인 <code>expect(locator).toHaveAccessibleErrorMessage()</code>를 추가해달라는 요청이었다.</p>
<p>3주 동안 w3c 스펙을 기반으로 <code>에러 메시지를 반환하는 경우</code>의 정의에 대한 토론과 추상화 및 피드백을 진행하였다.</p>
<hr>
<h2 id="2-context-파악">2. Context 파악</h2>
<p>요구사항이 생각보다 많이 복잡했다.
추상화를 진행하기 전, 아래의 것들을 파악했다.</p>
<blockquote>
<ol>
<li><a href="https://w3c.github.io/aria/#aria-errormessage">w3c Spec</a></li>
<li>다른 라이브러리의 추상화 방식</li>
</ol>
</blockquote>
<p>aria 속성과 에러메시지를 사용하여 이를 해결하려 했지만, maintainer님이 Input Validity의 속성도 파악하시길 원했다.</p>
<p>올바른 role 파악 &gt; aria-invalid 필드기반 토큰 반환 &gt; inputValidity 파악 &gt; 둘 중 문제가 하나라도 있는 경우 유효하지 않는 상태로 파악</p>
<p>3주 동안 추상화 방식와 테스트코드가 계속 변하였고 최종적으로 나오게된 피쳐는 아래와 같다.</p>
<hr>
<h2 id="3-테스트-코드">3. 테스트 코드</h2>
<p>TC에 대한 오버헤드를 우려해 엣지케이스에 대한 테스트들은 전부 확인한 뒤 제거한 코드이다.</p>
<p> 에러 메시지가 여러개인 경우, aria-invalid가 반환하는 여러가지 토큰들, inputValidity 자체의 값.
 이것들이 errorMessage를 반환하는 의사결정의 분기에 의존성이 있어 단순히 머릿속으로 이해한 뒤 추상화 하기엔 조금 어려웠다.
 머리를 오래 쓰기 싫어서 Spec 및 토의결과 기반으로 TDD를 진행했고, 오랜만에 그것의 효용성을 느끼게 되었다.
  (복잡한 Spec 및 많은 의사결정 분기 기반 피쳐는 TDD가 확실히 편한듯 하다) </p>
<pre><code class="language-ts">test(&#39;toHaveAccessibleErrorMessage&#39;, async ({ page }) =&gt; {
  await page.setContent(`
    &lt;form&gt;
      &lt;input role=&quot;textbox&quot; aria-invalid=&quot;true&quot; aria-errormessage=&quot;error-message&quot; /&gt;
      &lt;div id=&quot;error-message&quot;&gt;Hello&lt;/div&gt;
      &lt;div id=&quot;irrelevant-error&quot;&gt;This should not be considered.&lt;/div&gt;
    &lt;/form&gt;
  `);

  const locator = page.locator(&#39;input[role=&quot;textbox&quot;]&#39;);
  await expect(locator).toHaveAccessibleErrorMessage(&#39;Hello&#39;);
  await expect(locator).not.toHaveAccessibleErrorMessage(&#39;hello&#39;);
  await expect(locator).toHaveAccessibleErrorMessage(&#39;hello&#39;, { ignoreCase: true });
  await expect(locator).toHaveAccessibleErrorMessage(/ell\w/);
  await expect(locator).not.toHaveAccessibleErrorMessage(/hello/);
  await expect(locator).toHaveAccessibleErrorMessage(/hello/, { ignoreCase: true });
  await expect(locator).not.toHaveAccessibleErrorMessage(&#39;This should not be considered.&#39;);
});

test(&#39;toHaveAccessibleErrorMessage should handle multiple aria-errormessage references&#39;, async ({ page }) =&gt; {
  await page.setContent(`
    &lt;form&gt;
      &lt;input role=&quot;textbox&quot; aria-invalid=&quot;true&quot; aria-errormessage=&quot;error1 error2&quot; /&gt;
      &lt;div id=&quot;error1&quot;&gt;First error message.&lt;/div&gt;
      &lt;div id=&quot;error2&quot;&gt;Second error message.&lt;/div&gt;
      &lt;div id=&quot;irrelevant-error&quot;&gt;This should not be considered.&lt;/div&gt;
    &lt;/form&gt;
  `);

  const locator = page.locator(&#39;input[role=&quot;textbox&quot;]&#39;);

  await expect(locator).toHaveAccessibleErrorMessage(&#39;First error message. Second error message.&#39;);
  await expect(locator).toHaveAccessibleErrorMessage(/first error message./i);
  await expect(locator).toHaveAccessibleErrorMessage(/second error message./i);
  await expect(locator).not.toHaveAccessibleErrorMessage(/This should not be considered./i);
});

test.describe(&#39;toHaveAccessibleErrorMessage should handle aria-invalid attribute&#39;, () =&gt; {
  const errorMessageText = &#39;Error message&#39;;

  async function setupPage(page, ariaInvalidValue: string | null) {
    const ariaInvalidAttr = ariaInvalidValue === null ? &#39;&#39; : `aria-invalid=&quot;${ariaInvalidValue}&quot;`;
    await page.setContent(`
        &lt;form&gt;
          &lt;input id=&quot;node&quot; role=&quot;textbox&quot; ${ariaInvalidAttr} aria-errormessage=&quot;error-msg&quot; /&gt;
          &lt;div id=&quot;error-msg&quot;&gt;${errorMessageText}&lt;/div&gt;
        &lt;/form&gt;
      `);
    return page.locator(&#39;#node&#39;);
  }

  test.describe(&#39;evaluated in false&#39;, () =&gt; {
    test(&#39;no aria-invalid attribute&#39;, async ({ page }) =&gt; {
      const locator = await setupPage(page, null);
      await expect(locator).not.toHaveAccessibleErrorMessage(errorMessageText);
    });
    test(&#39;aria-invalid=&quot;false&quot;&#39;, async ({ page }) =&gt; {
      const locator = await setupPage(page, &#39;false&#39;);
      await expect(locator).not.toHaveAccessibleErrorMessage(errorMessageText);
    });
    test(&#39;aria-invalid=&quot;&quot; (empty string)&#39;, async ({ page }) =&gt; {
      const locator = await setupPage(page, &#39;&#39;);
      await expect(locator).not.toHaveAccessibleErrorMessage(errorMessageText);
    });
  });
  test.describe(&#39;evaluated in true&#39;, () =&gt; {
    test(&#39;aria-invalid=&quot;true&quot;&#39;, async ({ page }) =&gt; {
      const locator = await setupPage(page, &#39;true&#39;);
      await expect(locator).toHaveAccessibleErrorMessage(errorMessageText);
    });
    test(&#39;aria-invalid=&quot;foo&quot; (unrecognized value)&#39;, async ({ page }) =&gt; {
      const locator = await setupPage(page, &#39;foo&#39;);
      await expect(locator).toHaveAccessibleErrorMessage(errorMessageText);
    });
  });
});

test.describe(&#39;toHaveAccessibleErrorMessage should handle validity state with aria-invalid&#39;, () =&gt; {
  const errorMessageText = &#39;Error message&#39;;

  test(&#39;should show error message when validity is false and aria-invalid is true&#39;, async ({ page }) =&gt; {
    await page.setContent(`
      &lt;form&gt;
        &lt;input id=&quot;node&quot; role=&quot;textbox&quot; type=&quot;number&quot; min=&quot;1&quot; max=&quot;100&quot; aria-invalid=&quot;true&quot; aria-errormessage=&quot;error-msg&quot; /&gt;
        &lt;div id=&quot;error-msg&quot;&gt;${errorMessageText}&lt;/div&gt;
      &lt;/form&gt;
    `);
    const locator = page.locator(&#39;#node&#39;);
    await locator.fill(&#39;101&#39;);
    await expect(locator).toHaveAccessibleErrorMessage(errorMessageText);
  });

  test(&#39;should show error message when validity is true and aria-invalid is true&#39;, async ({ page }) =&gt; {
    await page.setContent(`
      &lt;form&gt;
        &lt;input id=&quot;node&quot; role=&quot;textbox&quot; type=&quot;number&quot; min=&quot;1&quot; max=&quot;100&quot; aria-invalid=&quot;true&quot; aria-errormessage=&quot;error-msg&quot; /&gt;
        &lt;div id=&quot;error-msg&quot;&gt;${errorMessageText}&lt;/div&gt;
      &lt;/form&gt;
    `);
    const locator = page.locator(&#39;#node&#39;);
    await locator.fill(&#39;99&#39;);
    await expect(locator).toHaveAccessibleErrorMessage(errorMessageText);
  });

  test(&#39;should show error message when validity is false and aria-invalid is false&#39;, async ({ page }) =&gt; {
    await page.setContent(`
      &lt;form&gt;
        &lt;input id=&quot;node&quot; role=&quot;textbox&quot; type=&quot;number&quot; min=&quot;1&quot; max=&quot;100&quot; aria-invalid=&quot;false&quot; aria-errormessage=&quot;error-msg&quot; /&gt;
        &lt;div id=&quot;error-msg&quot;&gt;${errorMessageText}&lt;/div&gt;
      &lt;/form&gt;
    `);
    const locator = page.locator(&#39;#node&#39;);
    await locator.fill(&#39;101&#39;);
    await expect(locator).toHaveAccessibleErrorMessage(errorMessageText);
  });

  test(&#39;should not show error message when validity is true and aria-invalid is false&#39;, async ({ page }) =&gt; {
    await page.setContent(`
      &lt;form&gt;
        &lt;input id=&quot;node&quot; role=&quot;textbox&quot; type=&quot;number&quot; min=&quot;1&quot; max=&quot;100&quot; aria-invalid=&quot;false&quot; aria-errormessage=&quot;error-msg&quot; /&gt;
        &lt;div id=&quot;error-msg&quot;&gt;${errorMessageText}&lt;/div&gt;
      &lt;/form&gt;
    `);
    const locator = page.locator(&#39;#node&#39;);
    await locator.fill(&#39;99&#39;);
    await expect(locator).not.toHaveAccessibleErrorMessage(errorMessageText);
  });
});</code></pre>
<hr>
<h2 id="4-추상화">4. 추상화</h2>
<p><a href="https://github.com/microsoft/playwright/pull/33904/files#diff-739493a094b44917bde78c66c5df6e44f13a5d0dd4c56bc705838572eab32390">&gt; role Util</a></p>
<p>Maintainer님의 의견에 따라 utils에 로직을 분리하고 기존 컨벤션대로 로직 추가</p>
<h3 id="role-utils">Role Utils</h3>
<pre><code class="language-ts">// https://www.w3.org/TR/wai-aria-1.2/#aria-invalid
const kAriaInvalidRoles = [&#39;application&#39;, &#39;checkbox&#39;, &#39;combobox&#39;, &#39;gridcell&#39;, &#39;listbox&#39;, &#39;radiogroup&#39;, &#39;slider&#39;, &#39;spinbutton&#39;, &#39;textbox&#39;, &#39;tree&#39;, &#39;columnheader&#39;, &#39;rowheader&#39;, &#39;searchbox&#39;, &#39;switch&#39;, &#39;treegrid&#39;];

function getAriaInvalid(element: Element): &#39;false&#39; | &#39;true&#39; | &#39;grammar&#39; | &#39;spelling&#39; {
  const role = getAriaRole(element) || &#39;&#39;;
  if (!role || !kAriaInvalidRoles.includes(role))
    return &#39;false&#39;;
  const ariaInvalid = element.getAttribute(&#39;aria-invalid&#39;);
  if (!ariaInvalid || ariaInvalid.trim() === &#39;&#39; || ariaInvalid.toLocaleLowerCase() === &#39;false&#39;)
    return &#39;false&#39;;
  if (ariaInvalid === &#39;true&#39; || ariaInvalid === &#39;grammar&#39; || ariaInvalid === &#39;spelling&#39;)
    return ariaInvalid;
  return &#39;true&#39;;
}

function getValidityInvalid(element: Element) {
  if (&#39;validity&#39; in element){
    const validity = element.validity as ValidityState | undefined;
    return validity?.valid === false;
  }
  return false;
}

export function getElementAccessibleErrorMessage(element: Element): string {
  // SPEC: https://w3c.github.io/aria/#aria-errormessage
  //
  // TODO: support https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/validationMessage
  const cache = cacheAccessibleErrorMessage;
  let accessibleErrorMessage = cacheAccessibleErrorMessage?.get(element);

  if (accessibleErrorMessage === undefined) {
    accessibleErrorMessage = &#39;&#39;;

    const isAriaInvalid = getAriaInvalid(element) !== &#39;false&#39;;
    const isValidityInvalid = getValidityInvalid(element);
    if (isAriaInvalid || isValidityInvalid) {
      const errorMessageId = element.getAttribute(&#39;aria-errormessage&#39;);
      const errorMessages = getIdRefs(element, errorMessageId);
      // Ideally, this should be a separate &quot;embeddedInErrorMessage&quot;, but it would follow the exact same rules.
      // Relevant vague spec: https://w3c.github.io/core-aam/#ariaErrorMessage.
      const parts = errorMessages.map(errorMessage =&gt; asFlatString(
          getTextAlternativeInternal(errorMessage, {
            visitedElements: new Set(),
            embeddedInDescribedBy: { element: errorMessage, hidden: isElementHiddenForAria(errorMessage) },
          })
      ));
      accessibleErrorMessage = parts.join(&#39; &#39;).trim();
    }
    cache?.set(element, accessibleErrorMessage);
  }
  return accessibleErrorMessage;
}</code></pre>
<h3 id="method">Method</h3>
<pre><code class="language-ts">export function toHaveAccessibleErrorMessage(
  this: ExpectMatcherState,
  locator: LocatorEx,
  expected: string | RegExp,
  options?: { timeout?: number; ignoreCase?: boolean },
) {
  return toMatchText.call(this, &#39;toHaveAccessibleErrorMessage&#39;, locator, &#39;Locator&#39;, async (isNot, timeout) =&gt; {
    const expectedText = serializeExpectedTextValues([expected], { ignoreCase: options?.ignoreCase, normalizeWhiteSpace: true });
    return await locator._expect(&#39;to.have.accessible.error.message&#39;, { expectedText: expectedText, isNot, timeout });
  }, expected, options);
}</code></pre>
<hr>
<h2 id="3주간-리뷰-그리고-merged">3주간 리뷰. 그리고 merged</h2>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/2aab6d9b-2242-4050-b7bd-1022eb7c69c4/image.png" alt=""></p>
<p>말한대로 3주 동안 추상화 방식, 에러 판단 방식 등 계속해서 변경되었다.</p>
<blockquote>
<p>추상화 &gt; 리뷰 요청 &gt; 피드백 &gt; ... 반복 &gt; merged</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/fa2c6727-6306-4783-be46-1a42ddeaff03/image.png" alt=""></p>
<p>3주 간의 41개의 커뮤니케이션 끝에 merged 되었다.</p>
<ul>
<li>maintainer님들에게 조금 죄송한 마음이 드는 PR이었다. 필자가 영어를 더 잘했고 공식 Spec을 어느정도 잘 숙지하고 있었다면 maintainer의 시간을 덜 뺐었을 수 있었을텐데...🥲
(공식문서 버젼별로 스펙이 조금씩 달랐던 부분이라 다른 문서를 보고 잘못 추상화 한 경우도 있었고, 영어 잘못 해석해서 추상화를 잘못한 경우도 있었다. 🥲)</li>
</ul>
<p>그래도 공식 스펙과 버젼을 인지하고 추상화하는 연습을 잘 할 수 있었던 PR이라 다음엔 더 나은 코드로 보답하는걸로...!</p>
<hr>
<h2 id="deploy-150">Deploy 1.50</h2>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/60f1804a-ecf5-4e0c-867b-d088f2111ebd/image.png" alt=""></p>
<p><a href="https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-have-accessible-error-message">&gt; Docs</a>
<a href="https://w3c.github.io/aria/#aria-errormessage">&gt; Spec</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[# 7. [Microsoft / playwright] feat(matcher): add new GenericAssertions(toBeOneOf)]]></title>
            <link>https://velog.io/@pengoose_dev/7.-Microsoft-playwright-featmatcher-add-new-GenericAssertionstoBeOneOf</link>
            <guid>https://velog.io/@pengoose_dev/7.-Microsoft-playwright-featmatcher-add-new-GenericAssertionstoBeOneOf</guid>
            <pubDate>Fri, 06 Dec 2024 06:53:51 GMT</pubDate>
            <description><![CDATA[<h2 id="1-issue">1. Issue</h2>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/73287979-77c1-45b4-8e41-ecf9fc72ffef/image.png" alt=""></p>
<p><a href="https://github.com/microsoft/playwright/issues/24232">issue #24232</a></p>
<p>토의가 활발히 일어났던 이슈.</p>
<p>GenericAssertions에 두 가지 메서드를 추가해달라는 요청이다.
해당 기능이 제공된다면, 조금 더 간단하고 직관적으로 여러 상황에 따른 case들을 테스트에서 핸들링할 수 있다.</p>
<p>두 개의 메서드를 전부 구현해 PR을 무겁게 생성하기보단,
<code>toBeOneOf</code>를 먼저 추상화 한 후 <strong>PR &gt; 리뷰 &gt; merged &gt; follow-PR</strong> 방식이 구현 및 리뷰자 관점에서 효율적이라 판단했다.</p>
<hr>
<h2 id="2-context-파악">2. Context 파악</h2>
<p><a href="https://github.com/pengooseDev/playwright/blob/eb395dab5b779e856efc68aba2fe308cdd23ea90/packages/playwright/bundles/expect/third_party/matchers.ts">코드 보기</a></p>
<p>matchers.ts에 여러 GenericAssertions들이 외부 모듈(jest)에 의존성을 강하게 가진 상태로 제공되고 있는 상태이다.</p>
<pre><code class="language-ts">// ex) MatchersObject.toBe()
const matchers: MatchersObject = {
  toBe(received: unknown, expected: unknown) {
    const matcherName = &#39;toBe&#39;;
    const options: MatcherHintOptions = {
      comment: &#39;Object.is equality&#39;,
      isNot: this.isNot,
      promise: this.promise,
    };

    const pass = Object.is(received, expected);

    const message = pass
      ? () =&gt;

        matcherHint(matcherName, undefined, undefined, options) +
        &#39;\n\n&#39; +
        `Expected: not ${printExpected(expected)}`
      : () =&gt; {
        const expectedType = getType(expected);

        let deepEqualityName = null;
        if (expectedType !== &#39;map&#39; &amp;&amp; expectedType !== &#39;set&#39;) {
          // If deep equality passes when referential identity fails,
          // but exclude map and set until review of their equality logic.
          if (
            equals(
                received,
                expected,
                [...this.customTesters, ...toStrictEqualTesters],
                true,
            )
          )
            deepEqualityName = &#39;toStrictEqual&#39;;
          else if (
            equals(received, expected, [
              ...this.customTesters,
              iterableEquality,
            ])
          )
            deepEqualityName = &#39;toEqual&#39;;

        }

        return (

          matcherHint(matcherName, undefined, undefined, options) +
          &#39;\n\n&#39; +
          (deepEqualityName !== null
            ? `${DIM_COLOR(
                `If it should pass with deep equality, replace &quot;${matcherName}&quot; with &quot;${deepEqualityName}&quot;`,
            )}\n\n`
            : &#39;&#39;) +
          printDiffOrStringify(
              expected,
              received,
              EXPECTED_LABEL,
              RECEIVED_LABEL,
              isExpand(this.expand),
          )
        );
      };

    // Passing the actual and expected objects so that a custom reporter
    // could access them, for example in order to display a custom visual diff,
    // or create a different error message
    return { actual: received, expected, message, name: matcherName, pass };
  },
</code></pre>
<p>어떤 방식으로 API를 제공하는지 파악하고 docs를 읽어 금방 context를 파악할 수 있었다.</p>
<hr>
<h2 id="3-회귀-테스트-추가">3. 회귀 테스트 추가</h2>
<p> 기존 TC와는 컨벤션이 많이 다른 것을 알 수 있다. 해당 모듈은 애초에 <code>third-party</code> 모듈로 구분되어, 가독성보단 다양한 테스트케이스를 적용해 엣지 케이스를 최소화 하는 것을 목표로 두는 것 같았다.</p>
<blockquote>
</blockquote>
<ul>
<li>playwright의 자체 API는 테스트 환경 구동에서 꽤 많은 리소스와 시간을 잡아먹는다. 그렇기에 테스트양이 많아질수록 오버헤드가 발생하는 것을 경계하라는 피드백을 받은 적이 있었다.<blockquote>
</blockquote>
다만, third-party 외부 라이브러리 테스트는 로컬에서 자체적인 빌드 후 커밋과 함께 스냅샷을 비교하는 테스트로 진행되고 있었다. (npm run ttest에서 1000번 후반대 테스트 진행 후 갑자기 1000개 가량이 짧은 시간 안에 실행되는 테스트)</li>
</ul>
<p>가독성이 떨어지는 건 알지만, 테스트 컨벤션이 아래와 같아 비슷하게 적용하였다.</p>
<pre><code class="language-ts">test.describe(&#39;.toBeOneOf()&#39;, () =&gt; {
  const matchingCases = [
    [2, [1, 2, 3]],
    [&#39;b&#39;, [&#39;a&#39;, &#39;b&#39;, &#39;c&#39;]],
    [true, [false, true]],
    [null, [undefined, null]],
    [undefined, [undefined, null]],
    [NaN, [NaN, 1, 2]],
    [BigInt(1), [BigInt(1), BigInt(2)]],
    [[1, 2], [[1, 2], [3, 4]]],
    [{ a: 1 }, [{ a: 1 }, { b: 2 }]],
    [{ a: { b: { c: 1 } } }, [{ a: { b: { c: 1 } } }, { a: { b: { c: 2 } } }]],
  ];

  const nonMatchingCases = [
    [4, [1, 2, 3]],
    [&#39;d&#39;, [&#39;a&#39;, &#39;b&#39;, &#39;c&#39;]],
    [false, [true]],
    [null, [undefined]],
    [undefined, [null]],
    [NaN, [1, 2]],
    [BigInt(3), [BigInt(1), BigInt(2)]],
    [[1, 2], [[3, 4], [5, 6]]],
    [{ a: 1 }, [{ b: 2 }, { c: 3 }]],
    [{ a: { b: { c: 1 } } }, [{ a: { b: { c: 2 } } }]],
  ];

  matchingCases.forEach(([value, array]: [any, any]) =&gt; {
    test(`passes when the value is in the expected array: expect(${stringify(value)}).toBeOneOf(${stringify(array)})`, () =&gt; {
      expectUnderTest(value).toBeOneOf(array);
    });
  });

  nonMatchingCases.forEach(([value, array]: [any, any]) =&gt; {
    test(`fails when the value is not in the expected array: expect(${stringify(value)}).toBeOneOf(${stringify(array)})`, () =&gt; {
      expect(() =&gt; expectUnderTest(value).toBeOneOf(array)).toThrowErrorMatchingSnapshot();
    });
  });

  nonMatchingCases.forEach(([value, array]: [any, any]) =&gt; {
    test(`passes when using .not and value is not in the expected array: expect(${stringify(value)}).not.toBeOneOf(${stringify(array)})`, () =&gt; {
      expectUnderTest(value).not.toBeOneOf(array);
    });
  });

  matchingCases.forEach(([value, array]: [any, any]) =&gt; {
    test(`fails when using .not and value is in the expected array: expect(${stringify(value)}).not.toBeOneOf(${stringify(array)})`, () =&gt; {
      expect(() =&gt; expectUnderTest(value).not.toBeOneOf(array)).toThrowErrorMatchingSnapshot();
    });
  });

  test(&#39;supports asymmetric matchers within the expected array&#39;, () =&gt; {
    expectUnderTest({ a: 1, b: 2 }).toBeOneOf([
      { a: 1 },
      expect.objectContaining({ b: 2 }),
    ]);
    expectUnderTest(&#39;hello world&#39;).toBeOneOf([
      expect.stringContaining(&#39;world&#39;),
      &#39;hello&#39;,
    ]);
  });

  test(&#39;fails when value does not match any asymmetric matchers in expected array&#39;, () =&gt; {
    expect(() =&gt;
      expectUnderTest({ a: 1, b: 2 }).toBeOneOf([
        { a: 2 },
        expect.objectContaining({ c: 3 }),
      ]),
    ).toThrowErrorMatchingSnapshot();
  });

  test(&#39;assertion error matcherResult property contains matcher name, expected and actual values&#39;, () =&gt; {
    const actual = 5;
    const expected = [1, 2, 3];
    try {
      expectUnderTest(actual).toBeOneOf(expected);
    } catch (error) {
      expect(error.matcherResult).toEqual(
          expect.objectContaining({
            actual,
            expected,
            name: &#39;toBeOneOf&#39;,
          }),
      );
    }
  });
});</code></pre>
<hr>
<h2 id="4-구현-및-문서작성">4. 구현 및 문서작성</h2>
<pre><code class="language-ts">  toBeOneOf(received: unknown, expected: Array&lt;unknown&gt;) {
    const matcherName = &#39;toBeOneOf&#39;;
    const isNot = this.isNot;
    const options: MatcherHintOptions = {
      isNot,
      promise: this.promise,
    };

    if (!Array.isArray(expected)) {
      throw new Error(
          matcherErrorMessage(
              matcherHint(matcherName, undefined, undefined, options),
              `${EXPECTED_COLOR(&#39;expected&#39;)} value must be an array`,
              printWithType(&#39;Expected&#39;, expected, printExpected),
          ),
      );
    }

    // ⛳️ Nested된 참조데이터용 deepEquals 알고리즘
    const pass = expected.some(item =&gt;
      equals(received, item, [...this.customTesters, iterableEquality]),
    );

    const message = () =&gt;
      matcherHint(matcherName, undefined, undefined, options) +
      &#39;\n\n&#39; +
      `Expected: ${isNot ? &#39;not &#39; : &#39;&#39;}to be one of ${printExpected(expected)}\n` +
      `Received: ${printReceived(received)}`;

    // ⛳️ 일부 메서드에선 pass, message만 제공하였으나, 확장성 있는 컨벤션 따르기로 함.
    return { actual: received, expected, message, name: matcherName, pass };
  },</code></pre>
<pre><code class="language-md">## method: GenericAssertions.toBeOneOf
* since: v1.49.1

Ensures that value is deeply equal to one of the elements in the expected array.

**Usage**

&#39;&#39;&#39;js
const value = 2;
expect(value).toBeOneOf([1, 2, 3]);
expect(value).not.toBeOneOf([4, 5, 6]);

const obj = { a: 1 };
expect(obj).toBeOneOf([{ a: 1 }, { b: 2 }]);
expect(obj).not.toBeOneOf([{ a: 2 }, { b: 3 }]);
&#39;&#39;&#39;

### param: GenericAssertions.toBeOneOf.expected
* since: v1.49.1
- `expected` &lt;[Array]&lt;[any]&gt;&gt;

Expected array to match against.</code></pre>
<hr>
<h2 id="5-pr-및-issue-closed">5. PR 및 Issue Closed</h2>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/04eeee8b-8911-4c10-bad4-1e4006a1533c/image.png" alt=""></p>
<p><a href="https://github.com/microsoft/playwright/pull/33852">&gt; PR</a></p>
<p> 구현하면서 조금 오래된 Issue이고 GenericAssertions가 1.9v이 업데이트되며, 최초에 제기된 issue를 해결할 수 있는 몇 가지 메서드가 추가되었다. 이를 뒤늦게 인지한 것은 구현이 끝난 뒤 문서작업 컨벤션을 확인하는 과정이었다.</p>
<p> 그래도 기능 자체는 다르기 때문에, 굳이 Issue창에서 묻는 것보단 PR을 남겨두고 피드백을 받는게 낫다고 판단하였다.</p>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/3784d39e-fde5-42a1-b810-55f881929843/image.png" alt=""></p>
<p>오픈소스 팀이 워낙 바쁘다보니 오래된 issue가 관리되지 않았던 것이다. 팀 내 토의를 통해, playwright에 해당 API를 지원하지 않으시는 것이 결론났고, PR은 closed되었다.</p>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/8463dca5-2d7c-4637-86c5-4c3783e39a0a/image.png" alt=""></p>
<p> 처음엔 조금 <code>아쉬움</code>이 들었다.(노력했으나 결과가 나오지 않았다고 생각했던 것 같다.)
 문득, 감정을 복기하다보니 몇 가지들을 깨닫게 되었고, 오히려 본질적인 기여에 한 걸음 다가갔다는 생각이 들기 시작했다.</p>
<hr>
<h2 id="6-기여의-본질">6. 기여의 본질?</h2>
<blockquote>
<p>우리는 종종 <code>형태</code>에 집착한다. 
형태에 집착하면, 이는 우리의 눈을 가리고 본질을 보지 못하게 한다.</p>
</blockquote>
<p>PR이 closed 되었을 때, 느껴진 <code>아쉬움</code>에서 내가 오픈소스에 가지고 있던 태도를 추론할 수 있었다.</p>
<ul>
<li>docs가 아닌 feat, fix에 대한 기여를 높이 평가하거나, Contribute 후 프로필 창에 기여 표시. 즉, 기여의 <code>형태</code>에 집착하고 있었던 것이다.</li>
</ul>
<p>물론, 이렇게 진행한 기여 또한 멋진 기여임에는 틀림이 없지만, 필자가 도달하고자 하는 이상적인 오픈소스 활동과는 거리가 있었다.</p>
<p> 필자가 이루고자 하는 <code>이상적인 기여</code>란 단순히 내가 작성한 코드의 병합을 의미하지 않았다. </p>
<p> 하나의 오픈소스에 꾸준히 활동하며 하나의 팀이 되어가는 것.
 PR이나 issue를 통해 피드백 받고, 토의하고, 팀의 컨벤션과 철학을 알아가며, 때로는 현재 PR처럼 팀들이 신경쓰지 못하는 부분을 서포팅 해주는 것이 진정한 하나의 팀. 그리고 기여가 되어가는 과정이라고 생각하기 때문이다.</p>
<blockquote>
<p>쉽게 말해 필자가 생각하는 &quot;기여&quot;란 <code>팀에 도움</code>이 되었고 <strong>모종의 가치를 창출</strong>했다면 그걸로 OK라는 것이다.</p>
</blockquote>
<p> 즉, playwright 팀이 신경쓰지 못했던 부분을 PR을 통해 팀 토의로 이끌고, issue를 close 하였다는 그 자체가 멋진 기여라고 생각되었다.</p>
<p> <img src="https://velog.velcdn.com/images/pengoose_dev/post/985b4a9a-2128-4d0d-a27f-ac73d893b7d9/image.png" alt=""></p>
<hr>
<h2 id="나는-처음이지만-maintainer는-아닐껄">나는 처음이지만, maintainer는 아닐껄?</h2>
<p>분명, 오픈소스 maintainer들은 이런 상황(PR Close)에 직면한 적이 여러번 존재할 것이다.
PR이 Closed 된다면 그 기여자가 다시 해당 프로젝트에 기여할 확률이 줄어든다는 것을 그들은 경험적으로 알 것이라 생각된다. 거절은 종종 상처로 다가오기 때문이다.</p>
<p> 오픈소스 maintainer들은 기여자의 감정적인 부분들까지 신경써줘야 한다는 것을 깨닫고, 그들이 짊어진 무게를 조금 더 알게 되었다.</p>
<p> 몇몇 오픈소스에는 기여 과정에서 아쉬웠던 부분들이 종종 존재했으나, 가장 즐거운 기여 경험은 playwright라고 장담할 수 있다. 멋진 팀의 일원이 될 수 있기를 바란다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[# 6. [Microsoft / playwright] fix(expect): adjust normalization for regex values in toHaveText matcher]]></title>
            <link>https://velog.io/@pengoose_dev/6.-Microsoft-playwright-fixexpect-adjust-normalization-for-regex-values-in-toHaveText-matcher</link>
            <guid>https://velog.io/@pengoose_dev/6.-Microsoft-playwright-fixexpect-adjust-normalization-for-regex-values-in-toHaveText-matcher</guid>
            <pubDate>Wed, 13 Nov 2024 06:25:59 GMT</pubDate>
            <description><![CDATA[<h2 id="1-issue">1. Issue</h2>
<p><a href="https://github.com/microsoft/playwright/issues/29382">issue #29382</a></p>
<blockquote>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/136de13e-a721-4481-beb5-a21376e5508c/image.png" alt=""></p>
</blockquote>
<p>toHaveText 매개변수로 정규표현식 담긴 배열 넘기면 맞아도 틀렸다 뜨는 문제.</p>
<p>쉬워보이는데 왜 열린지 9개월째 해결이 안됐을까?
아래의 사진(번역)이 그 이유를 알려준다.</p>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/8ed806e9-4d50-429e-989b-3fda9892e74f/image.png" alt=""></p>
<p>diff의 message를 print하는 로직은 외부 유틸함수(jest)에 위임된 상태여서 내부 diff 로직이 사용자에게 커스텀 matcher(diff로직) API를 제공하지 않는다면 해결하기 어려운 문제라고 언급해서 그런듯하다.</p>
<p>필자는 vitest쪽에서 비대칭 매처쪽 코드를 많이 봤었기 때문에, 이번에도 일단 헤딩 시작!</p>
<hr>
<h2 id="2-context-파악">2. Context 파악</h2>
<ol>
<li>코드보기</li>
</ol>
<pre><code class="language-ts">export function toHaveText(
  this: ExpectMatcherState,
  locator: LocatorEx,
  expected: string | RegExp | (string | RegExp)[],
  options: { timeout?: number, useInnerText?: boolean, ignoreCase?: boolean } = {},
) {
  // ⛳️ 1. 배열인 경우니까.
  if (Array.isArray(expected)) {
    // ⛳️ 2. toEqual 먼저 따보기 됨.
    return toEqual.call(this, &#39;toHaveText&#39;, locator, &#39;Locator&#39;, async (isNot, timeout) =&gt; {
      const expectedText = serializeExpectedTextValues(expected, { normalizeWhiteSpace: true, ignoreCase: options.ignoreCase });
      return await locator._expect(&#39;to.have.text.array&#39;, { expectedText, isNot, useInnerText: options?.useInnerText, timeout });
    }, expected, options);
  } else {
    return toMatchText.call(this, &#39;toHaveText&#39;, locator, &#39;Locator&#39;, async (isNot, timeout) =&gt; {
      const expectedText = serializeExpectedTextValues([expected], { normalizeWhiteSpace: true, ignoreCase: options.ignoreCase });
      return await locator._expect(&#39;to.have.text&#39;, { expectedText, isNot, useInnerText: options?.useInnerText, timeout });
    }, expected, options);
  }
}
</code></pre>
<ol start="2">
<li><p>toEqual 확인</p>
<pre><code class="language-ts">export async function toEqual&lt;T&gt;(
this: ExpectMatcherState,
matcherName: string,
receiver: Locator,
receiverType: string,
query: (isNot: boolean, timeout: number) =&gt; Promise&lt;{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }&gt;,
expected: T,
options: { timeout?: number, contains?: boolean } = {},
): Promise&lt;MatcherResult&lt;any, any&gt;&gt; {
expectTypes(receiver, [receiverType], matcherName);

const matcherOptions = {
 comment: options.contains ? &#39;&#39; : &#39;deep equality&#39;,
 isNot: this.isNot,
 promise: this.promise,
};

const timeout = options.timeout ?? this.timeout;

const { matches: pass, received, log, timedOut } = await query(!!this.isNot, timeout);
if (pass === !this.isNot) {
 return {
   name: matcherName,
   message: () =&gt; &#39;&#39;,
   pass,
   expected
 };
}

let printedReceived: string | undefined;
let printedExpected: string | undefined;
let printedDiff: string | undefined;
if (pass) { // ⛳️ 1. 정규 표현식이 있으면 무조건 fail이니까
 printedExpected = `Expected: not ${this.utils.printExpected(expected)}`;
 printedReceived = `Received: ${this.utils.printReceived(received)}`;
} else {
 // ⛳️ 2. 여기에 구현하면 끝남. 아래 코드는 외부 jest 유틸함수.
 // 즉, 외부 의존성 있는 유틸함수에 값 넘겨주기 전에, 정규화 해주면 해결되는 문제.
 // - 배열 여부 판단
 // - expect가 regex인 경우, received값이 expect의 regex에 match 될 경우, expect 값을 received로 변경시켜 외부 Diff 알고리즘에 통과하도록 조정해주면 됨.
 printedDiff = this.utils.printDiffOrStringify(
     expected,
     received,
     EXPECTED_LABEL,
     RECEIVED_LABEL,
     false,
 );
}
const message = () =&gt; {
 const header = matcherHint(this, receiver, matcherName, &#39;locator&#39;, undefined, matcherOptions, timedOut ? timeout : undefined);
 const details = printedDiff || `${printedExpected}\n${printedReceived}`;
 return `${header}${details}${callLogText(log)}`;
};
// Passing the actual and expected objects so that a custom reporter
// could access them, for example in order to display a custom visual diff,
// or create a different error message
return {
 actual: received,
 expected, message,
 name: matcherName,
 pass,
 log,
 timeout: timedOut ? timeout : undefined,
};
}</code></pre>
</li>
</ol>
<hr>
<h2 id="3-회귀-테스트-추가">3. 회귀 테스트 추가</h2>
<p>이슈 내용 그대로 매개변수로 <code>(string|RegExp)[]</code> 넘겨줬을 때 regex가 match될 때 에러 안던지면 됨.</p>
<pre><code class="language-ts">test(&#39;should only highlight unmatched regex in diff message for toHaveText with array&#39;, async ({ runInlineTest }) =&gt; {
  const result = await runInlineTest({
    &#39;a.spec.ts&#39;: `
      import { test, expect } from &#39;@playwright/test&#39;;
      test(&#39;toHaveText with mixed strings and regexes (array)&#39;, async ({ page }) =&gt; {
        await page.setContent(\`
          &lt;ul&gt;
            &lt;li&gt;Coffee&lt;/li&gt;
            &lt;li&gt;Tea&lt;/li&gt;
            &lt;li&gt;Milk&lt;/li&gt;
          &lt;/ul&gt;
        \`);
        const items = page.locator(&#39;li&#39;);
        await expect(items).toHaveText([&#39;Coffee&#39;, /\\d+/, /Milk/]);
      });
    `,
  });
  expect(result.exitCode).toBe(1);
  const output = result.output;
  expect(output).toContain(&#39;-   /\\d+/,&#39;); // 숫자값에 match되지 않았으니 에러 메시지에 있어야 함.
  expect(output).toContain(&#39;+   &quot;Tea&quot;,&#39;); // 위 regex랑 페어. 문자열이니 에러 메시지에 있어야 함.
  // 해당 유닛테스트의 본질. 내 코드가 있을때만, 아래의 테스트가 통과해야함.
  expect(output).not.toContain(&#39;-   /Milk/,&#39;); // regex에 match되니 에러 메시지에 없어야 함.
  expect(output).not.toContain(&#39;-   &quot;Coffee&quot;,&#39;); // 그냥 기본으로 통과되어야 함.
});</code></pre>
<hr>
<h2 id="4-해결">4. 해결</h2>
<pre><code class="language-ts">if (pass) {
    printedExpected = `Expected: not ${this.utils.printExpected(expected)}`;
    printedReceived = `Received: ${this.utils.printReceived(received)}`;
  // ⛳️ 1. 배열 쌍인 경우 중,
  // (제네릭 타입이기 때문에 (string | RegExp)[]가 아닐 수 있으므로 분기처리)
  } else if (Array.isArray(expected) &amp;&amp; Array.isArray(received)) {
    const normalizedExpected = expected.map((exp, index) =&gt; {
      const rec = received[index];
      // ⛳️ 2. received 값이 RegExp인 경우
      if (isRegExp(exp))
        // ⛳️ 3. expect의 값을 receive와 동일하게 변경
        // 커스텀 플래그(&quot;[match]&quot;)를 세울까 하다가 원본 값을 최대한 유지(rec =&gt; exp)하는 것으로 선택
        return exp.test(rec) ? rec : exp;

      return exp;
    });
    printedDiff = this.utils.printDiffOrStringify(
        // ⛳️ 4. 정규화된 expected 넘겨주기
        normalizedExpected,
        received,
        EXPECTED_LABEL,
        RECEIVED_LABEL,
        false,
    );
  } else {
    printedDiff = this.utils.printDiffOrStringify(
        expected,
        received,
        EXPECTED_LABEL,
        RECEIVED_LABEL,
        false,
    );
  }</code></pre>
<hr>
<h2 id="5-pr-및-merged">5. PR 및 merged</h2>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/97946679-26dc-430f-9038-2849ec974878/image.png" alt=""></p>
<p>간단한 변경사항 요청 후 merged
<img src="https://velog.velcdn.com/images/pengoose_dev/post/1422543b-cd34-4ce7-b1dd-1be5b417dd16/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/91599b21-1c3e-46ca-9474-351f3720e55b/image.png" alt=""></p>
<p><a href="https://github.com/microsoft/playwright/pull/33533">&gt; PR #33533</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[# 5. [Microsoft / playwright] feat(testType): add support for test.fail.only method #33001]]></title>
            <link>https://velog.io/@pengoose_dev/5.-playwright-feattestType-add-support-for-test.fail.only-method-33001</link>
            <guid>https://velog.io/@pengoose_dev/5.-playwright-feattestType-add-support-for-test.fail.only-method-33001</guid>
            <pubDate>Thu, 17 Oct 2024 15:35:53 GMT</pubDate>
            <description><![CDATA[<h2 id="1-issue">1. Issue</h2>
<p><a href="https://github.com/microsoft/playwright/issues/30662">issue #30662</a></p>
<blockquote>
<p>test.fail.only 구현해주세요.</p>
</blockquote>
<p>이슈창에서 1차 방향성 확인</p>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/b958e748-ad8e-493a-bc19-2b6febe5869f/image.png" alt=""></p>
<hr>
<h2 id="2-context-파악">2. Context 파악</h2>
<ol>
<li>testType이 추상화 된 파일을 찾아, 디깅.
fail을 찾아 파일을 따라가보니, <code>TestTypeImpl</code>에 동작 방식이 추상화 되어있음. (아래 코드는 fail.only 추상화가 끝나있는 코드)</li>
</ol>
<pre><code class="language-ts">export class TestTypeImpl {
  readonly fixtures: FixturesWithLocation[];
  readonly test: TestType&lt;any, any&gt;;

  constructor(fixtures: FixturesWithLocation[]) {
    this.fixtures = fixtures;

    const test: any = wrapFunctionWithLocation(this._createTest.bind(this, &#39;default&#39;));
    test[testTypeSymbol] = this;
    test.expect = expect;
    test.only = wrapFunctionWithLocation(this._createTest.bind(this, &#39;only&#39;));
    test.describe = wrapFunctionWithLocation(this._describe.bind(this, &#39;default&#39;));
    test.describe.only = wrapFunctionWithLocation(this._describe.bind(this, &#39;only&#39;));
    test.describe.configure = wrapFunctionWithLocation(this._configure.bind(this));
    test.describe.fixme = wrapFunctionWithLocation(this._describe.bind(this, &#39;fixme&#39;));
    test.describe.parallel = wrapFunctionWithLocation(this._describe.bind(this, &#39;parallel&#39;));
    test.describe.parallel.only = wrapFunctionWithLocation(this._describe.bind(this, &#39;parallel.only&#39;));
    test.describe.serial = wrapFunctionWithLocation(this._describe.bind(this, &#39;serial&#39;));
    test.describe.serial.only = wrapFunctionWithLocation(this._describe.bind(this, &#39;serial.only&#39;));
    test.describe.skip = wrapFunctionWithLocation(this._describe.bind(this, &#39;skip&#39;));
    test.beforeEach = wrapFunctionWithLocation(this._hook.bind(this, &#39;beforeEach&#39;));
    test.afterEach = wrapFunctionWithLocation(this._hook.bind(this, &#39;afterEach&#39;));
    test.beforeAll = wrapFunctionWithLocation(this._hook.bind(this, &#39;beforeAll&#39;));
    test.afterAll = wrapFunctionWithLocation(this._hook.bind(this, &#39;afterAll&#39;));
    test.skip = wrapFunctionWithLocation(this._modifier.bind(this, &#39;skip&#39;));
    test.fixme = wrapFunctionWithLocation(this._modifier.bind(this, &#39;fixme&#39;));
    test.fail = wrapFunctionWithLocation(this._modifier.bind(this, &#39;fail&#39;));
    test.fail.only = wrapFunctionWithLocation(this._createTest.bind(this, &#39;fail.only&#39;));
    test.slow = wrapFunctionWithLocation(this._modifier.bind(this, &#39;slow&#39;));
    test.setTimeout = wrapFunctionWithLocation(this._setTimeout.bind(this));
    test.step = this._step.bind(this);
    test.use = wrapFunctionWithLocation(this._use.bind(this));
    test.extend = wrapFunctionWithLocation(this._extend.bind(this));
    test.info = () =&gt; {
      const result = currentTestInfo();
      if (!result)
        throw new Error(&#39;test.info() can only be called while test is running&#39;);
      return result;
    };
    this.test = test;
  }

  private _currentSuite(location: Location, title: string): Suite | undefined {
    const suite = currentlyLoadingFileSuite();
    if (!suite) {
      throw new Error([
        `Playwright Test did not expect ${title} to be called here.`,
        `Most common reasons include:`,
        `- You are calling ${title} in a configuration file.`,
        `- You are calling ${title} in a file that is imported by the configuration file.`,
        `- You have two different versions of @playwright/test. This usually happens`,
        `  when one of the dependencies in your package.json depends on @playwright/test.`,
      ].join(&#39;\n&#39;));
    }
    return suite;
  }

  private _createTest(type: &#39;default&#39; | &#39;only&#39; | &#39;skip&#39; | &#39;fixme&#39; | &#39;fail&#39; | &#39;fail.only&#39;, location: Location, title: string, fnOrDetails: Function | TestDetails, fn?: Function) {
    throwIfRunningInsideJest();
    const suite = this._currentSuite(location, &#39;test()&#39;);
    if (!suite)
      return;

    let details: TestDetails;
    let body: Function;
    if (typeof fnOrDetails === &#39;function&#39;) {
      body = fnOrDetails;
      details = {};
    } else {
      body = fn!;
      details = fnOrDetails;
    }

    const validatedDetails = validateTestDetails(details);
    const test = new TestCase(title, body, this, location);
    test._requireFile = suite._requireFile;
    test._staticAnnotations.push(...validatedDetails.annotations);
    test._tags.push(...validatedDetails.tags);
    suite._addTest(test);

    if (type === &#39;only&#39; || type === &#39;fail.only&#39;)
      test._only = true;
    if (type === &#39;skip&#39; || type === &#39;fixme&#39; || type === &#39;fail&#39;)
      test._staticAnnotations.push({ type });
    else if (type === &#39;fail.only&#39;)
      test._staticAnnotations.push({ type: &#39;fail&#39; });
  }

  private _describe(type: &#39;default&#39; | &#39;only&#39; | &#39;serial&#39; | &#39;serial.only&#39; | &#39;parallel&#39; | &#39;parallel.only&#39; | &#39;skip&#39; | &#39;fixme&#39;, location: Location, titleOrFn: string | Function, fnOrDetails?: TestDetails | Function, fn?: Function) {
    throwIfRunningInsideJest();
    const suite = this._currentSuite(location, &#39;test.describe()&#39;);
    if (!suite)
      return;

    let title: string;
    let body: Function;
    let details: TestDetails;

    if (typeof titleOrFn === &#39;function&#39;) {
      title = &#39;&#39;;
      details = {};
      body = titleOrFn;
    } else if (typeof fnOrDetails === &#39;function&#39;) {
      title = titleOrFn;
      details = {};
      body = fnOrDetails;
    } else {
      title = titleOrFn;
      details = fnOrDetails!;
      body = fn!;
    }

    const validatedDetails = validateTestDetails(details);
    const child = new Suite(title, &#39;describe&#39;);
    child._requireFile = suite._requireFile;
    child.location = location;
    child._staticAnnotations.push(...validatedDetails.annotations);
    child._tags.push(...validatedDetails.tags);
    suite._addSuite(child);

    if (type === &#39;only&#39; || type === &#39;serial.only&#39; || type === &#39;parallel.only&#39;)
      child._only = true;
    if (type === &#39;serial&#39; || type === &#39;serial.only&#39;)
      child._parallelMode = &#39;serial&#39;;
    if (type === &#39;parallel&#39; || type === &#39;parallel.only&#39;)
      child._parallelMode = &#39;parallel&#39;;
    if (type === &#39;skip&#39; || type === &#39;fixme&#39;)
      child._staticAnnotations.push({ type });

    for (let parent: Suite | undefined = suite; parent; parent = parent.parent) {
      if (parent._parallelMode === &#39;serial&#39; &amp;&amp; child._parallelMode === &#39;parallel&#39;)
        throw new Error(&#39;describe.parallel cannot be nested inside describe.serial&#39;);
      if (parent._parallelMode === &#39;default&#39; &amp;&amp; child._parallelMode === &#39;parallel&#39;)
        throw new Error(&#39;describe.parallel cannot be nested inside describe with default mode&#39;);
    }

    setCurrentlyLoadingFileSuite(child);
    body();
    setCurrentlyLoadingFileSuite(suite);
  }

  private _hook(name: &#39;beforeEach&#39; | &#39;afterEach&#39; | &#39;beforeAll&#39; | &#39;afterAll&#39;, location: Location, title: string | Function, fn?: Function) {
    const suite = this._currentSuite(location, `test.${name}()`);
    if (!suite)
      return;
    if (typeof title === &#39;function&#39;) {
      fn = title;
      title = `${name} hook`;
    }

    suite._hooks.push({ type: name, fn: fn!, title, location });
  }

  // ... codes

  private _modifier(type: &#39;skip&#39; | &#39;fail&#39; | &#39;fixme&#39; | &#39;slow&#39;, location: Location, ...modifierArgs: any[]) {
    const suite = currentlyLoadingFileSuite();
    if (suite) {
      if (typeof modifierArgs[0] === &#39;string&#39; &amp;&amp; typeof modifierArgs[1] === &#39;function&#39; &amp;&amp; (type === &#39;skip&#39; || type === &#39;fixme&#39; || type === &#39;fail&#39;)) {
        // Support for test.{skip,fixme,fail}(title, body)
        this._createTest(type, location, modifierArgs[0], modifierArgs[1]);
        return;
      }
      if (typeof modifierArgs[0] === &#39;string&#39; &amp;&amp; typeof modifierArgs[1] === &#39;object&#39; &amp;&amp; typeof modifierArgs[2] === &#39;function&#39; &amp;&amp; (type === &#39;skip&#39; || type === &#39;fixme&#39; || type === &#39;fail&#39;)) {
        // Support for test.{skip,fixme,fail}(title, details, body)
        this._createTest(type, location, modifierArgs[0], modifierArgs[1], modifierArgs[2]);
        return;
      }

      if (typeof modifierArgs[0] === &#39;function&#39;) {
        suite._modifiers.push({ type, fn: modifierArgs[0], location, description: modifierArgs[1] });
      } else {
        if (modifierArgs.length &gt;= 1 &amp;&amp; !modifierArgs[0])
          return;
        const description = modifierArgs[1];
        suite._staticAnnotations.push({ type, description });
      }
      return;
    }

    const testInfo = currentTestInfo();
    if (!testInfo)
      throw new Error(`test.${type}() can only be called inside test, describe block or fixture`);
    if (typeof modifierArgs[0] === &#39;function&#39;)
      throw new Error(`test.${type}() with a function can only be called inside describe block`);
    testInfo[type](...modifierArgs as [any, any]);
  }

  // ...codes
}</code></pre>
<p>위 코드를 읽어보면, 몇 가지 특징을 알 수 있다.</p>
<h3 id="1-annotation-관리">1. Annotation 관리</h3>
<p>nested 메서드를 동적으로 파싱해주는 줄 알았는데, 그냥 only는 <code>_only</code>필드로, 나머지 속성들은 <code>_staticAnnotations</code>에서 처리함을 알 수 있었다. (Annotation Type이 리터럴 유니온이 아닌 string이던데, 나중에 이슈 남기고 히스토리가 있는게 아니라면 PR 날릴 예정)</p>
<h3 id="2-nested-메서드-추상화">2. nested 메서드 추상화</h3>
<p>Test 필드의 메서드의 메서드를 추상화하는 issue였기에 <code>_describe</code> 추상화 방식과, commit history에서 도움을 많이 받았다. 다만, 주의해야 하는게, describe는 <code>test</code>가 아닌 <code>Suite</code>(테스트 뭉치)이다. 즉, createTest와 공통점보다 차이점이 많아 별도의 메서드로 분리하여 사용(<code>this._describe.bind</code>)함을 알 수 있다. 적당히 보고 참고할 부분만 참고하자.</p>
<h3 id="3-type과-docs는-빌드-타입에-생성된다">3. Type과 Docs는 빌드 타입에 생성된다</h3>
<p> 위 코드와는 관련이 없지만, playwright의 type 문서는 빌드 타임에 동적으로 생성된다. 다만, 이에 대한 문서 가이드가 없어서 조금 혼란을 겪었다. 어떻게 관리하는지 <code>타입이 변경되는 커밋 히스토리</code>를 까보면 되겠다싶어서 하나하나 까보다보니, <code>utils/generate_types</code>에서 관리되고 있었다.</p>
<p> 이 부분에서 시간을 꽤 많이 허비했는데, 마침 이와 관련된 <a href="https://github.com/microsoft/playwright/pull/33138">CONTRIBUTE 업데이트</a>가 추가되었다.</p>
<p> 문서를 읽고 간단한 검토를 남기다, type, docs에 대한 명시적 언급을 제안하였다. 
문제는...
<img src="https://velog.velcdn.com/images/pengoose_dev/post/7bd0ae79-cc85-4f4a-b6aa-513c7de878a7/image.png" alt=""></p>
<p>??? : 아니 이미 추가했잖아.</p>
<p>문서를 자세히 보니 해당 내용이 존재했다. 다만, 영어권이 아닌 사람들이 한 눈에 익히기에는 조금 하이라이팅이 부족하다고 느껴졌다. 뭔가 조금 죄송한 마음도 들어서 조용히 댓글로만 <a href="https://github.com/microsoft/playwright/pull/33138#issuecomment-2419743557">변경사항 제안</a>을 남겨두었다.</p>
<hr>
<h2 id="3-기능-구현">3. 기능 구현</h2>
<p>잠시 삼천포에 빠졌다. 다시 원래대로 돌아오자.
 기능 구현은 너무 간단하다.</p>
<blockquote>
<p>위 코드대로 &#39;fail.only&#39;일 때, Annotation과 <code>_only</code> 필드를 적절히 추가해준다. 추상화 끝! 😉</p>
</blockquote>
<hr>
<h2 id="4-타입-추가">4. 타입 추가</h2>
<p>타입을 추가해줄 차례이다. 이 부분에서 조금 고민이 많았다.</p>
<blockquote>
<ol>
<li>only 속성이니, only의 함수 시그니쳐를 따르자. (첫 커밋)</li>
<li>앗. 근데 보통 우리가 only를 쓸 때 TC를 다 써두고, 특정 테스트만 확인할 때 붙이지 않나? 그럼 fail이 작성된 상태에서 fail.only로 바뀔텐데, fail의 시그니처를 따르는게 UX적으로 더 좋겠네. (두 번째 커밋)</li>
</ol>
</blockquote>
<p><code>Maintainer</code> : 엥 이전이랑 함수 시그니처 왜 바뀌었나요? only에 없는 fail 시그니처 지우세요.
<code>Pengoose</code> : 네! (내가 모르는 히스토리나 컨벤션이 있구나. 일단 따르자! 혹시 추후 Issue가 나오면, 그때 토의 해보는걸로!)</p>
<hr>
<h2 id="5-회귀-테스트-추가">5. 회귀 테스트 추가</h2>
<p>기존 only 테스트에 영향이 없는 테스트에 fail.only 테스트까지 추가해주고, 
(예를들어, 여러개의 only. 즉, test.describe.only, test.only에 대한 테스트)
<img src="https://velog.velcdn.com/images/pengoose_dev/post/8a43003f-e6d4-4817-8161-ab7bca3ece1c/image.png" alt=""></p>
<p>기존 only, describe.only의 컨벤션을 따라 회귀 테스트 작성
나머지는 Reg TC는 너무 기니까 <a href="https://github.com/microsoft/playwright/pull/33001/files#diff-39110e075b775344ef7f22d9613f4f7254d0de826d13e0dd4c0a0e217de62c78">링크</a>로!</p>
<hr>
<h2 id="6-pr-및-merged">6. PR 및 merged</h2>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/647a4cf1-9456-40c3-a342-48448d221e51/image.png" alt=""></p>
<p><a href="https://github.com/microsoft/playwright/pull/33001">&gt; PR #33001</a></p>
<hr>
<h2 id="7-release">7. Release</h2>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/406014ba-2e05-4473-bf6d-8b0c2409ef23/image.png" alt=""></p>
<p><a href="https://github.com/microsoft/playwright/releases/tag/v1.49.0">&gt; v1.49.0</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[간단한 GPT API 응답 언어 개선 솔루션]]></title>
            <link>https://velog.io/@pengoose_dev/GPT-API-%EC%9D%91%EB%8B%B5-%EC%96%B8%EC%96%B4-%EA%B0%9C%EC%84%A0-%EC%86%94%EB%A3%A8%EC%85%98</link>
            <guid>https://velog.io/@pengoose_dev/GPT-API-%EC%9D%91%EB%8B%B5-%EC%96%B8%EC%96%B4-%EA%B0%9C%EC%84%A0-%EC%86%94%EB%A3%A8%EC%85%98</guid>
            <pubDate>Thu, 10 Oct 2024 15:45:18 GMT</pubDate>
            <description><![CDATA[<h2 id="1-문제-정의">1. 문제 정의</h2>
<p>GPT API나 개인 인스턴스 사용 시, 질문과 다른 언어로 답변하는 문제가 종종 발생한다. 특히 파일 검색이나 인터넷 검색을 사용할 때 빈번하게 나타난다.</p>
<p> 프롬프트 개선으로 이를 해결하고자 하였지만, 생각보다 문제가 해결되지 않아 코드 레벨에서 이를 해결하고자 한다.</p>
<h3 id="귀납적-경험">귀납적 경험</h3>
<blockquote>
<p>경험상, 질문의 마지막에 언어를 명시하면(예: &quot;한국어로 답변해줘&quot;) 문제는 대부분 해결된다. 이를 자동화하기 위해 질문의 언어를 감지해 적절한 답변 요청 문구를 추가하는 방식으로 해결하고자 한다.</p>
</blockquote>
<p> 사용자의 질문 언어를 감지해 해당 언어로 답변을 요청하는 Suffix를 자동으로 추가하는 방식으로 언어 오류를 최소화하고자 한다.</p>
<hr>
<h2 id="2-간단한-해결책">2. 간단한 해결책</h2>
<p> 구현은 너무나 간단하다. 브루트포스로 문자열을 순회하며 regex(또는 아스키 코드)로 언어의 특성을 파악하여 해시맵에 가중치를 기록하는 선형적으로 증가하는 <code>O(n)</code> 방식이다.</p>
<pre><code class="language-ts">type Language = &#39;en&#39; | &#39;ko&#39; | &#39;ja&#39;;

export const Prompt = {
  getLanguage(message: string): Language {
    const weight: Record&lt;Language, number&gt; = {
      en: 0,
      ko: 0,
      ja: 0,
    };

    for (const char of message) {
      if (/[a-zA-Z]/.test(char)) {
        weight.en++;
      } else if (/[ㄱ-ㅎ|ㅏ-ㅣ|가-힣]/.test(char)) {
        weight.ko++;
      } else if (/[\u3040-\u30ff\u4e00-\u9faf]/.test(char)) {
        weight.ja++;
      }
    }

    const detectedLanguage = (Object.keys(weight) as Language[]).reduce(
      (maxLang, lang) =&gt; (weight[lang] &gt; weight[maxLang] ? lang : maxLang),
      &#39;en&#39; as Language
    );

    return detectedLanguage;
  },

  addSuffix(message: string) {
    const languageSuffixMap = {
      ko: &#39;한국어로 답변해주세요.&#39;,
      en: &#39;Please answer in English.&#39;,
      ja: &#39;日本語で回答してください.&#39;,
    };

    const languageKey = Prompt.getLanguage(message);

    return `${message} ${languageSuffixMap[languageKey]}`;
  },
};
</code></pre>
<h2 id="3-통계적-표본-검출을-통한-최적화">3. 통계적 표본 검출을 통한 최적화</h2>
<p> 평균적으로 들어오는 메시지는 100~150자 사이로, 띄어쓰기를 제외(한국 사용자 기준) 모든 문자를 검사하는 대신, 메시지의 일부만 샘플링해도 충분히 정확한 언어 감지가 가능하다. 이를 통해 성능을 개선하고자 한다.</p>
<blockquote>
<p>메시지 길이에 <strong>간격 샘플링</strong>을 진행하여 k번째 문자마다 샘플링하여 성능과 정확성의 균형을 맞춰 시간복잡도를 <code>O(n)</code>에서 <code>O(n/k)</code>. 로 변경하고자 한다.</p>
</blockquote>
<pre><code class="language-ts">// k번째 문자만 검토하도록 수정
for (let i = 0; i &lt; message.length; i += k) {
  const char = message[i];
  if (/[a-zA-Z]/.test(char)) {
    weight.en++;
  } else if (/[ㄱ-ㅎ|ㅏ-ㅣ|가-힣]/.test(char)) {
    weight.ko++;
  } else if (/[\u3040-\u30ff\u4e00-\u9faf]/.test(char)) {
    weight.ja++;
  }
}</code></pre>
<hr>
<h2 id="가설-검증">가설 검증</h2>
<blockquote>
<h3 id="적절한-k-">적절한 <code>k</code> ?</h3>
<p>띄어쓰기로 나눠지는 단위를 <code>청크</code>라고 가정하자.(일어는 띄어쓰기가 없으니 제외한다.)</p>
</blockquote>
<h3 id="한국어">한국어:</h3>
<p>한국어는 보통 조사와 어미가 하나의 단어에 추가로 결합된다. 하나의 청크에는 본래의 단어(2<del>4자가 70</del>80%를 차지한다)와 조사나 어미(1<del>2자)가 결합되어, 평균적으로 한 청크는 3</del>5자 정도일 것으로 예상하였다.</p>
<h3 id="영어">영어:</h3>
<p>영어는 조사나 어미 대신 전치사나 대명사 같은 별도의 단어들이 따로 표기된다. 즉, 하나의 청크는 기본적으로 단어 자체만으로 구성된다. 단어의 평균 길이가 약 4.7<del>5.4자이므로, 하나의 청크는 보통 4</del>5자로 구성된다.</p>
<h3 id="일본어">일본어:</h3>
<p>일본어는 조사나 접속사 같은 요소들이 단어에 결합되지 않고 독립적으로 쓰이는 경우가 대부분으로 보인다. 다만, 문법상의 특성(동사 변화나 조사의 영향)으로 청크가 길어질 수 있다. 하나의 단어(한자 포함)는 평균 4<del>5자 정도이며, 여기에 조사나 어미가 추가되어, 하나의 청크는 6</del>8자 정도로 추산한다.</p>
<h5 id="ref--한국어-형태소-분석기-ham의-형태소-분석-및-철자-검사-기능"><a href="https://www.koreascience.kr/article/CFKO199629013517396.pdf">&gt; ref : 한국어 형태소 분석기 HAM의 형태소 분석 및 철자 검사 기능</a></h5>
<h5 id="ref--linguisticscentral"><a href="https://linguisticscentral.com/how-many-letters-are-in-an-average-word/">&gt; ref : Linguisticscentral</a></h5>
<h5 id="ref--inter-contact"><a href="https://www.inter-contact.de/en/blog/text-length-languages">&gt; ref : inter-contact</a></h5>
<hr>
<h2 id="4-통계적-표본-검출을-통한-적절한-k-값-산출">4. 통계적 표본 검출을 통한 적절한 <code>k</code> 값 산출</h2>
<p>모집단에서 적절한 표본 크기를 구하기 위해 통계학에서는 <strong>신뢰도</strong>와 <strong>오차범위</strong>를 고려한 표본 추출 방식을 사용한다.</p>
<p>실데이터(평균 메시지 길이)의 크기를 기반으로 적절한 <code>k</code> 값을 구하기 위해 <strong>신뢰 구간</strong>을 적용한다.</p>
<h3 id="신뢰도-도출-및-trade-off-의사결정">신뢰도 도출 및 Trade-off 의사결정</h3>
<pre><code class="language-md">n = (Z^2 * p * (1-p)) / e^2
// n: 신뢰 임계치를 넘기기 위해, 최소로 샘플링 해야하는 표본 문자열의 크기
// Z: 신뢰도에 따른 Z값 (99% 신뢰도에서 Z = 2.576)
// p: 모집단의 비율 (언어가 잘못 분류될 확률을 50%로 가정. 보수적 계산)
// e: 허용 오차 (일반적으로 5%)</code></pre>
<p>이를 통해 필요한 표본 크기를 계산하자. 한국 사용자 기준으로 평균적인 질문 메시지의 길이는 100자에서 200자 사이였다. 여기서는 높은 신뢰도를 위해 모집단의 평균을 100자로 계산한다.</p>
<p>99% 신뢰도를 넘기기 위해 필요한 표본 크기(n)는 약 <code>87(87%)</code>자를 샘플링해야 충분히 높은 신뢰도로 언어를 감지할 수 있음을 의미한다.</p>
<p><code>k</code> 값은 약 <code>1.15</code>로 이는 사실상 거의 모든 문자마다 샘플링하는 방식에 가깝다. 
개선되는 시간복잡도는 굉장히 미미하지만, 언어가 잘못 매핑되어 UX를 해치는 기대값을 생각했을 때, 득보다는 실이 크다고 판단되었다.</p>
<hr>
<h2 id="5-결론">5. 결론</h2>
<p> 문자열이 굉장히 긴 경우, 위에서 언급한대로 표본 추출을 통해 시간복잡도를 개선하거나, 나아가 사용하는 언어별 아스키코드를 360도로 분배하여 원점으로부터 기울기 및 좌표를 계산하는 방식으로 최적화가 가능해보인다. 시간복잡도 개선을 위한 다양한 아이디어 적용이 가능해보인다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[#4. [shadcn-chat] 올바른 interface 상속을 통한 textArea 컴포넌트 조합문자 처리 개선]]></title>
            <link>https://velog.io/@pengoose_dev/4.-shadcn-chat-%EC%98%AC%EB%B0%94%EB%A5%B8-interface-%EC%83%81%EC%86%8D%EC%9D%84-%ED%86%B5%ED%95%9C-textArea-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8-%EC%A1%B0%ED%95%A9%EB%AC%B8%EC%9E%90-%EC%B2%98%EB%A6%AC-%EA%B0%9C%EC%84%A0</link>
            <guid>https://velog.io/@pengoose_dev/4.-shadcn-chat-%EC%98%AC%EB%B0%94%EB%A5%B8-interface-%EC%83%81%EC%86%8D%EC%9D%84-%ED%86%B5%ED%95%9C-textArea-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8-%EC%A1%B0%ED%95%A9%EB%AC%B8%EC%9E%90-%EC%B2%98%EB%A6%AC-%EA%B0%9C%EC%84%A0</guid>
            <pubDate>Sun, 29 Sep 2024 05:49:59 GMT</pubDate>
            <description><![CDATA[<h2 id="1-issue">1. Issue</h2>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/58e5ed63-ffc5-4911-bc7f-79dddba44f4e/image.png" alt=""></p>
<p>shadcn-chat에서 제공하는 ChatInput 컴포넌트는 <code>TextArea를 한 단계 더 추상화한 고차 컴포넌트</code>이다.
 textArea에서 composition-based language를 submit할 경우, 마지막 단어가 중복되어 전달되는 이슈가 존재한다.
 <a href="https://developer.mozilla.org/en-US/docs/Web/API/Element/compositionstart_event">onComposition</a> (<a href="https://legacy.reactjs.org/docs/events.html#composition-events">**react</a>) 메서드로 제어하고자 하였으나 interface에서 이를 제공하지 않아 에러가 발생하였다. (기능적으로는 동작. 따라서 type선언 문제)</p>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/90b9c9a1-740e-4e23-9cef-08599ca8ad4f/image.png" alt=""></p>
<hr>
<h2 id="2-원인-파악">2. 원인 파악</h2>
<p>원인은 <code>interface 상속</code>에 있었다.
 하나의 컴포넌트를 다시 추상화하는 과정에서, 의도적으로 기능을 제한하는 경우가 아니라면 기존에 사용하던 interface를 적절히 잘 상속해주는 것이 중요하다.</p>
<h3 id="prev">Prev</h3>
<pre><code class="language-ts">import { Textarea } from &quot;@/components/ui/textarea&quot;;
import { cn } from &quot;@/lib/utils&quot;;

// ⛳️ TextArea를 상속하여 그대로 spread하는 것이 아닌 직접 추상화하여 상속
interface ChatInputProps {
  className?: string;
  value?: string;
  onKeyDown?: (event: React.KeyboardEvent&lt;HTMLTextAreaElement&gt;) =&gt; void;
  onChange?: (event: React.ChangeEvent&lt;HTMLTextAreaElement&gt;) =&gt; void;
  placeholder?: string;
}

const ChatInput = React.forwardRef&lt;HTMLTextAreaElement, ChatInputProps&gt;(
  ({ className, value, onKeyDown, onChange, placeholder, ...props }, ref) =&gt; (
    &lt;Textarea
      autoComplete=&quot;off&quot;
      value={value}
      ref={ref}
      onKeyDown={onKeyDown}
      onChange={onChange}
      name=&quot;message&quot;
      placeholder={placeholder}
      className={cn(
        &quot;max-h-12 px-4 py-3 bg-background text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 w-full rounded-md flex items-center h-16 resize-none&quot;,
        className,
      )}
      {...props}
    /&gt;
  ),
);
ChatInput.displayName = &quot;ChatInput&quot;;
export { ChatInput };</code></pre>
<p>물론, 해당 모듈은 기존의 모듈을 다시 추상화한 컴포넌트이기 때문에, 기존 shadcn의 <code>Textarea</code> 컴포넌트 의존성으로 이런 추상화가 발생한 것이 아닌지 히스토리를 체크해야한다.</p>
<h3 id="textareashadcn">Textarea(shadcn)</h3>
<pre><code class="language-ts">import * as React from &quot;react&quot;

import { cn } from &quot;@/lib/utils&quot;

export interface TextareaProps
  extends React.TextareaHTMLAttributes&lt;HTMLTextAreaElement&gt; {}

const Textarea = React.forwardRef&lt;HTMLTextAreaElement, TextareaProps&gt;(
  ({ className, ...props }, ref) =&gt; {
    return (
      &lt;textarea
        className={cn(
          &quot;flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50&quot;,
          className
        )}
        ref={ref}
        {...props}
      /&gt;
    )
  }
)
Textarea.displayName = &quot;Textarea&quot;

export { Textarea }
</code></pre>
<p>그런거 없었다. 그저 <code>실수</code>였다는 것을 확인하였으니 상속되는 interface를 정정해주자.
두 가지 선택지가 있다.</p>
<ol>
<li><p><code>HTMLTextAreaElement</code> 상속하기</p>
</li>
<li><p>의존성 모듈을 사용하니 shadcn에서 사용한 <code>Textarea</code> 그대로 상속하기.</p>
<p>물론, 2가 더 바람직하겠지만, 기존 모듈에서 추상화 해둔 방식을 살펴보니, shadcn에서 추상화 해둔 interface가 아닌 전부 직접 추상화 하여 상속하는 방식을 채택했다.
이럴 경우, shadcn에서 제공하는 interface가 HTML API가 아닌 자체 선언방식으로 변경되면 해당 모듈 또한 재작업을 진행해야 한다는 문제점이 발생하지만, HeadlessUI(shadcn) 특성상 HTML API를 벗어날 가능성이 낮다고 판단하여 기존 컨벤션(<code>HTMLTextAreaElement</code> 상속하기)을 따르기로 하였다.</p>
</li>
</ol>
<hr>
<h2 id="3-해결">3. 해결</h2>
<p>interface를 올바르게 상속하고, 불필요한 매개변수를 제거하는 간단한 작업 후, PR하였다.</p>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/c95eb74c-4d27-44d8-ac79-fe52dd99000c/image.png" alt=""></p>
<hr>
<h2 id="4-pr-및-merged">4. PR 및 merged</h2>
<p><a href="https://github.com/jakobhoeg/shadcn-chat/pull/55">&gt; PR #1410</a></p>
<p>maintainer가 한 분이시고, contribution에 대한 문서 작성이 TODO에 담겨있던 초기 프로젝트라 LinkedIn 프로필로 gentle ping을 PR 컨텍스트를 한 번 날리게 되었다.</p>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/51578fae-8e24-4146-9059-964442d5b1d0/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/d35d7636-9aad-4fcb-a98c-7237e8ee629c/image.png" alt=""></p>
<p>Merge 후 서윗하게 DM까지 보내주셨다.
Maintainer의 gentle함이 오픈소스에 주는 영향을 크게 느끼게 되었다.
한동안 멈췄던 오픈소스를 다시 잡아볼까 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[PS -완-]]></title>
            <link>https://velog.io/@pengoose_dev/PS-%EC%99%84-</link>
            <guid>https://velog.io/@pengoose_dev/PS-%EC%99%84-</guid>
            <pubDate>Thu, 18 Jul 2024 17:28:05 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/bb856d34-e1e4-44a6-b2c4-40ad55efed78/image.png" alt=""></p>
<h3 id="ps-근황">PS 근황</h3>
<p>목표였던 D5를 달성하였다.</p>
<p>취업 후 한동안 PS와 CP를 손에서 놓았지만, 부트캠프의 알고리즘 멘토 제안과 준아님이 추천해준 백준방에서 자극을 받아 다시 PS를 잡게 되었다.</p>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/6e1c3f0d-df1e-466b-aa6f-bdbabbd6b23e/image.png" alt=""></p>
<hr>
<h3 id="어디서-왔는가">어디서 왔는가?</h3>
<p>나의 PS는 어떻게 시작됐을까?
<a href="https://velog.io/@pengoose_dev/PS6-%EA%B3%A8%EB%93%9C3-%EB%8B%AC%EC%84%B1">&gt; 1년 전 PS 이야기(G3 달성)</a></p>
<p>비단 PS 뿐 아니라, 삶에서 <strong><code>운</code></strong>이라는 요소는 꽤 크게 작용한다.</p>
<blockquote>
<ol>
<li>23년 5월 <code>면접</code>에서 면접관님의 알고리즘, 자료구조 중요성 강조</li>
<li><code>디스코드 작업방</code>에서 백준 풀던 걸 <code>준아님</code>이 발견. 알고리즘 가이드 + CP(앳코더) 추천 해주심.</li>
<li>성장(G5 =&gt; G4 =&gt; ... G1)</li>
<li><code>앳코더 참여</code> + 백준 P5 달성</li>
<li>취업 및 휴식기</li>
<li>부트캠프 알고리즘 멘토 + 백준방 참여 =&gt; CP, 알고리즘 다시 시작</li>
</ol>
</blockquote>
<p>내 PS. 나아가 개발자의 삶에서의 <strong>운</strong>.
즉, 내가 멈추지 않고 나아갈 수 있도록 돕는 <strong>스승</strong>이자 <strong>친구</strong>이자 <strong>환경</strong>.
나아가 <strong>방향성</strong>과 <strong>통찰력</strong>. 그리고 <strong>가능성</strong>을 제공해준 사람은 <strong><code>준아님</code></strong>이었다.</p>
<p>마침 저번 휴일에 준아님과 오랜만에 (거의 반년..?) 식사를 했다.
노래방 3시간 야무지게 땡겼는데, 나에게는 항상 감사한 스승이자 친구다.
고마운 사람들에게 더 나은 영향력을 끼칠 수 있도록, 함께 정진해야겠다.</p>
<blockquote>
<p>준아님 항상 감쟈(potato 아님)합니다.</p>
</blockquote>
<hr>
<h3 id="어디로-갈-것인가">어디로 갈 것인가?</h3>
<ul>
<li>목표를 달성했으니 새로운 알고리즘 학습은 한 동안 삼가야겠다.</li>
<li>다만, 배운걸 기반으로 CP는 꾸준히 참여할 것 같다.</li>
<li>준아님이 추천해주신 Serverless와 AWS 강의실 톡방에서 활동 및 학습</li>
<li>위의 것들이 끝난다면 서비스 런칭</li>
</ul>
<hr>
<h3 id="감사합니다">감사합니다.</h3>
<p>항상 주변 사람들에게 감사할 따름이다.</p>
<blockquote>
<ul>
<li>PS, 인프라에 대해 알려주시고 모각코, 노래방 야물딱지게 즐겨주시는 준아님</li>
</ul>
</blockquote>
<ul>
<li>힘들었던 시기부터 항상 응원해주고 따듯하게 곁을 지켜준 동하형</li>
<li>오픈소스 알려주신 영준님</li>
<li>한 번씩 안부 전해주시는 강준님</li>
<li>오픈소스 자극 받게 해주시는 종훈님, 인제님.</li>
<li>그리고 항상 같이 작업하는 작업방 분들.</li>
</ul>
<p>감사합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[#3. [vanilla-extract] Fix: createTheme 자동완성 지원]]></title>
            <link>https://velog.io/@pengoose_dev/vanilla-extract-Fix-createTheme-%EC%9E%90%EB%8F%99%EC%99%84%EC%84%B1-%EC%A7%80%EC%9B%90</link>
            <guid>https://velog.io/@pengoose_dev/vanilla-extract-Fix-createTheme-%EC%9E%90%EB%8F%99%EC%99%84%EC%84%B1-%EC%A7%80%EC%9B%90</guid>
            <pubDate>Fri, 14 Jun 2024 08:52:10 GMT</pubDate>
            <description><![CDATA[<h2 id="1-issue">1. Issue</h2>
<p><a href="https://github.com/vanilla-extract-css/vanilla-extract/issues/1267">&gt; Issue #1267</a>
createTheme에 themeContract를 넣어주면 보통 자동완성(타입 추론)이 지원되어야하는데, 그렇지 않아 불편함이 많았다.</p>
<hr>
<h2 id="2-원인-파악">2. 원인 파악</h2>
<p>원인은 함수 오버로딩 순서에 있었다.
TS 컴파일러는 가장 먼저 매칭(상단)되는 시그니처를 찾아 그 시그니처에 맞는 타입을 함수의 타입으로 해석한다.</p>
<pre><code class="language-ts">export function createTheme&lt;ThemeTokens extends Tokens&gt;(
  tokens: ThemeTokens, // ⛳
  debugId?: string,
): [className: string, vars: ThemeVars&lt;ThemeTokens&gt;];
export function createTheme&lt;ThemeContract extends Contract&gt;(
  themeContract: ThemeContract, // ⛳
  tokens: MapLeafNodes&lt;ThemeContract, string&gt;,
  debugId?: string,
): string;
export function createTheme(arg1: any, arg2?: any, arg3?: string): any {
  const themeClassName = generateIdentifier(
    typeof arg2 === &#39;object&#39; ? arg3 : arg2,</code></pre>
<p>보통 유저가 빈번하게 사용하는 함수 시그니처는 두 번째 시그니처이다.
createTheme의 첫 매개변수로 객체를 넘겨주는 과정에서 첫 번째 함수 시그니쳐가 매핑되었고, themeContract를 넘겨준 상태에서 두 번째 매개변수가 적절한 타입 추론을 제공하지 못하였다.</p>
<hr>
<h2 id="3-해결">3. 해결</h2>
<p>함수 오버로딩 순서를 변경해주는 것으로 문제를 해결했다. 다만, 이에 대한 사이드 이펙트도 고려해야한다.
 두 번째 함수 시그니처를 첫 번째로 옮기더라도 변경된 함수 시그니처(기존의 첫 번째 시그니처)의 두 번째 매개변수는 optional한 string값이기 때문에 해당 함수 시그니처를 사용하는 상황에서도 사이드 이펙트가 크지 않을 것이라 판단하였다.</p>
<pre><code class="language-ts">export function createTheme&lt;ThemeContract extends Contract&gt;(
  themeContract: ThemeContract, // ⛳
  tokens: MapLeafNodes&lt;ThemeContract, string&gt;,
  debugId?: string,
): string;
export function createTheme&lt;ThemeTokens extends Tokens&gt;(
  tokens: ThemeTokens,
  debugId?: string,
): [className: string, vars: ThemeVars&lt;ThemeTokens&gt;];
export function createTheme(arg1: any, arg2?: any, arg3?: string): any {
  const themeClassName = generateIdentifier(
    typeof arg2 === &#39;object&#39; ? arg3 : arg2,</code></pre>
<p>해당 부분은 maintainer의 판단에 맡기기로 하였고, 사이드 이펙트(tradeOff)가 존재한다면 실제 사용자의 사용 빈도에 따라 함수 시그니처의 순서를 정렬하는 것이 맞아보였다.</p>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/57e65bc9-9853-4c17-bbe9-f9bc37bfba69/image.png" alt=""></p>
<p>함수 시그니처 추론에 대한 사이드 이펙트가 없다고 판단하셨는지, 사용빈도는 크게 상관이 없을 것 같다고 피드백을 주셨다.
바로 merge.</p>
<hr>
<h2 id="4-pr-및-merged">4. PR 및 merged</h2>
<p><a href="https://github.com/vanilla-extract-css/vanilla-extract/pull/1410">&gt; PR #1410</a>
Closed된 PR들을 살펴보면 vanilla-extract는 외부 기여를 많이 받지 않는 것 같았다.
<img src="https://velog.velcdn.com/images/pengoose_dev/post/2ec8158e-3884-4b50-a0d3-fc32a8269df7/image.png" alt=""></p>
<p>거의 1달 전에 날렸던 PR이라 잊고 있었는데 어떻게 잘 merge되었다.
😉👍</p>
<hr>
<h2 id="5-release">5. Release</h2>
<p><a href="https://github.com/vanilla-extract-css/vanilla-extract/releases/tag/%40vanilla-extract%2Fcss%401.15.3">&gt; @vanilla-extract/css@1.15.3</a>
<img src="https://velog.velcdn.com/images/pengoose_dev/post/63d63d18-45fd-48ad-9021-02a1cb27567d/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[#2. [Vitest] Fix: 중첩된 test suite 내부 sequential, concurrent 오버라이딩 기능 지원]]></title>
            <link>https://velog.io/@pengoose_dev/Vitest-a-5vtpw1bh</link>
            <guid>https://velog.io/@pengoose_dev/Vitest-a-5vtpw1bh</guid>
            <pubDate>Mon, 03 Jun 2024 09:48:57 GMT</pubDate>
            <description><![CDATA[<h2 id="1-issue">1. Issue</h2>
<h4 id="issue-5716"><a href="https://github.com/vitest-dev/vitest/issues/5716">&gt; Issue #5716</a></h4>
<p>concurrent 설정을 한 하위 depth의 test suite에 sequantial 설정을 오버라이딩하면 적용이 되지않는 문제가 존재한다.</p>
<hr>
<h2 id="2-원인-파악">2. 원인 파악</h2>
<pre><code class="language-ts">function createSuite() {
  function suiteFn(this: Record&lt;string, boolean | undefined&gt;, name: string | Function, factoryOrOptions?: SuiteFactory | TestOptions, optionsOrFactory: number | TestOptions | SuiteFactory = {}) {
    const mode: RunMode = this.only ? &#39;only&#39; : this.skip ? &#39;skip&#39; : this.todo ? &#39;todo&#39; : &#39;run&#39;
    const currentSuite = getCurrentSuite()
    let { options, handler: factory } = parseArguments(
      factoryOrOptions,
      optionsOrFactory,
    )
    // inherit options from current suite
    if (currentSuite?.options)
      options = { ...currentSuite.options, ...options }

    // ⛳️ 이곳이 문제다.
    options.concurrent = this.concurrent || (!this.sequential &amp;&amp; options?.concurrent)
    options.sequential = this.sequential || (!this.concurrent &amp;&amp; options?.sequential)

    return createSuiteCollector(formatName(name), factory, mode, this.shuffle, this.each, options)
  }</code></pre>
  <br/>

<p>명시적으로 값을 할당하지 않을 때, undefined인 경우를 고려해야해서 복잡도가 빠르게 올라간 코드의 사례라고 생각된다. 해당 코드는 다른 코드에서 의존성이 굉장히 높았으며, this와 config를 동시에 사용하고 있어 경우의 수가 굉장히 많아 버그가 발생한 사례이다.(의존성이 있는 다른 함수에서도 비슷하게 undefined 및 this와 매개변수의 혼용 사용으로 인한 복잡도--부채--가 많이 쌓여있는 상태였다.)</p>
<p> 전역으로 코드를 변경하려다, 해당 부분만 수정하면 잦게 변경사항이 발생할 부분이 아니라고 판단해서 변경을 최소화하고 변수 선언을 통해 가독성을 확보하는 쪽으로 결정하였다.</p>
<hr>
<h2 id="3-해결">3. 해결</h2>
<ol>
<li><p>기존에 통과하지 못하는 회귀 테스트의 주석을 풀어주고,
<img src="https://velog.velcdn.com/images/pengoose_dev/post/8c914306-ccd0-4a7b-9407-ee2021eed100/image.png" alt=""></p>
</li>
<li><p>기존 상속 로직을 아래와 같이 변경.</p>
</li>
</ol>
<pre><code class="language-ts">    // inherit concurrent / sequential from suite
    const isConcurrent = options.concurrent || (this.concurrent &amp;&amp; !this.sequential)
    const isSequential = options.sequential || (this.sequential &amp;&amp; !this.concurrent)
    options.concurrent = isConcurrent &amp;&amp; !isSequential
    options.sequential = isSequential &amp;&amp; !isConcurrent</code></pre>
<br/>

<ol start="3">
<li>회귀 테스트 통과 확인
<img src="https://velog.velcdn.com/images/pengoose_dev/post/bf9991ca-d3dc-4576-ab88-02ec636521d0/image.png" alt=""></li>
</ol>
<hr>
<h2 id="4-pr-및-merged">4. PR 및 merged</h2>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/1dc00576-d1fd-48b7-a93f-8c94315d545c/image.png" alt=""></p>
<h4 id="prmerged"><a href="https://github.com/vitest-dev/vitest/pull/5737">&gt; PR(merged)</a></h4>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/9d2d6dbe-1d7f-4273-91fd-e4382caa9254/image.png" alt=""></p>
<p>샤라메트 형님과 인제님의 따봉도치 👍</p>
<hr>
<h2 id="vitest-v200-beta5-배포">Vitest v2.0.0-beta.5 배포</h2>
<p><a href="https://github.com/vitest-dev/vitest/releases/tag/v2.0.0-beta.5">https://github.com/vitest-dev/vitest/releases/tag/v2.0.0-beta.5</a></p>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/d490cc05-6941-4540-ab5e-be4f36e066d5/image.png" alt=""></p>
<h2 id="vitest-v2-배포">Vitest v2 배포</h2>
<p><a href="https://github.com/vitest-dev/vitest/releases/tag/v2.0.0">https://github.com/vitest-dev/vitest/releases/tag/v2.0.0</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[#1. [Vitest] Fix: ArrayBuffer와 비트 연산자를 이용한 NaN 부호판단 및 올바른 파싱 지원]]></title>
            <link>https://velog.io/@pengoose_dev/Vitest-format-%EB%A1%9C%EC%A7%81-%EC%88%98%EC%A0%95</link>
            <guid>https://velog.io/@pengoose_dev/Vitest-format-%EB%A1%9C%EC%A7%81-%EC%88%98%EC%A0%95</guid>
            <pubDate>Sun, 26 May 2024 07:12:00 GMT</pubDate>
            <description><![CDATA[<h4 id="5744-testeach-does-not-print--0-and--nan-correctly"><a href="https://github.com/vitest-dev/vitest/issues/5744">[#5744] test.each does not print -0 and -NaN correctly</a></h4>
<hr>
<h2 id="1-문제-상황">1. 문제 상황</h2>
<p>각각 [-0], [-NaN]가 주어졌을 때, 예상과 다르게 포매팅이 진행된다는 것이다.</p>
<h3 id="예상">예상</h3>
<pre><code class="language-shell"> ✓ test/basic.test.ts (2)
   ✓ -0 // ⛳
   ✓ -NaN // ⛳</code></pre>
<h3 id="실제-결과">실제 결과</h3>
<pre><code class="language-shell"> ✓ test/basic.test.ts (2)
   ✓ 0 // ⛳
   ✓ NaN // ⛳</code></pre>
<hr>
<h2 id="2-원인-파악">2. 원인 파악</h2>
<p>다음은 vitest에 추상화되어있는 test suite의 description(describe, test, it)의 문자열 포매팅 함수(format)의 일부이다.</p>
<pre><code class="language-ts">case &#39;%f&#39;: return Number.parseFloat(String(args[i++])).toString()</code></pre>
<h4 id="위-코드는-아래의-경우를-제대로-처리하지-못한다">위 코드는 아래의 경우를 제대로 처리하지 못한다.</h4>
<pre><code class="language-ts">String(-0)
// &#39;0&#39;

String(-NaN)
// &#39;NaN&#39;</code></pre>
<hr>
<h2 id="3-트러블슈팅-nan">3. 트러블슈팅(-NaN)</h2>
<p>분기처리를 통해 edgeCase를 처리하였다.
이 과정에서 <code>-0</code>을 처리하는 것은 문제가 없었지만 <strong><code>-NaN</code></strong>을 처리하는 것은 조금 난이도가 있었다.
이유는 아래와 같다.</p>
<pre><code class="language-ts">NaN;
// &gt; NaN

-NaN;
// &gt; NaN // ⛳ -NaN은 초기화 시점에서 -NaN이 아니라 NaN으로 평가된다.

NaN === NaN;
// false

Object.is(NaN, -NaN); // ⛳
// true</code></pre>
<p>JS가 -NaN을 추상화 하는 과정에서 위 사이드 이펙트를 고려하지 못한 것 같았다.
따라서, 정상적인 negative 값인지 판단하는 것이 불가능하다.</p>
<pre><code class="language-ts">String(-NaN);
// &#39;NaN&#39;</code></pre>
<hr>
<h2 id="4-해결-아이디어-발견-sign-bit와-ieee-표준">4. 해결 아이디어 발견: (sign bit와 IEEE 표준)</h2>
<p>검색하며 문제 해결에 대한 아이디어를 찾아보았다.
IEEE와 관련된 웹사이트를 돌아다니다 NaN에 대한 규약에서 아이디어를 얻었고, JS에서 sign bit에 접근하여 이에 대한 값(부호)를 확인할 수 있다면 해결할 수 있는 문제였다.</p>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/ccf5b5d1-b2fe-4071-bc48-30252976b7fe/image.png" alt=""></p>
<p><a href="https://www.doc.ic.ac.uk/~eedwards/compsys/float/nan.html">&gt; IEEE</a>
<a href="https://foldoc.org/IEEE+floating+point">&gt; FLODOC</a></p>
<hr>
<h2 id="5-해결">5. 해결</h2>
<p>값을 64비트 부동 소수점 배열로 변경한 뒤, 값 할당 이후 buffer값을 32비트 정수 배열로 바꿔 signIndex(최상위 인덱스. 즉, 부호)에 접근하여 1(음수)인지 확인하면 -NaN의 부호를 확인할 수 있는지 확인할 수 있었다.</p>
<pre><code class="language-ts">/**
 * Checks if a given number is NaN and if it is a negative NaN.
 *
 * @param {number} val - The number to check.
 * @returns {boolean} - True if the number is a negative NaN, false otherwise.
 */
export function isNegativeNaN(val: number): boolean {
  // NaN이 아니면 ealry return.
  if (!Number.isNaN(val)) return false;

  // 64비트배열 생성
  const f64 = new Float64Array(1);
  // Float64Array [0, buffer: ArrayBuffer(8), byteLength: 8, byteOffset: 0, length: 1, Symbol(Symbol.toStringTag): &#39;Float64Array&#39;]

  // 값 할당
  f64[0] = val;
  // Float64Array [NaN, buffer: ArrayBuffer(8), byteLength: 8, byteOffset: 0, length: 1, Symbol(Symbol.toStringTag): &#39;Float64Array&#39;]

  // buffer값으로 32비트 정수 배열 생성
  const u32 = new Uint32Array(f64.buffer);
  // Uint32Array(2) [0, 4294443008, buffer: ArrayBuffer(8), byteLength: 8, byteOffset: 0, length: 2, Symbol(Symbol.toStringTag): &#39;Uint32Array&#39;]

  // 상위 32비트(index 1)의 마지막 비트가 음수(1)인지 확인
  const isNegative = u32[1] &gt;&gt;&gt; 31 === 1;

  return isNegative;
}</code></pre>
<p>잘 작동한다. 👍</p>
<pre><code class="language-ts">isNegativeNaN(-NaN);
// true

isNegativeNaN(NaN);
// false

isNegativeNaN(-0);
// false

// ...</code></pre>
<hr>
<h2 id="6-pr-및-모듈-배포">6. PR 및 모듈 배포</h2>
<p>애초에 -NaN을 쓴다는 것 자체가 잘못된 개념이긴 하지만, 이에 관한 엣지케이스가 대부분의 라이브러리에 되어있지 않다는 것을 확인하였다. 
조만간 vitest 말고도 다른 오픈소스에 작업을 진행할 예정이다.</p>
<p>PR 후, 다른사람들 편하게 쓰라고 배포까지 완료하였다.</p>
<h3 id="pr"><a href="https://github.com/vitest-dev/vitest/pull/5775">&gt; PR</a></h3>
<h3 id="npm"><a href="https://www.npmjs.com/package/is-negative-nan">&gt; npm</a></h3>
<hr>
<h2 id="7-maintainer님의-빠꾸">7. Maintainer님의 빠꾸</h2>
<p><a href="https://github.com/vitest-dev/vitest/pull/5775">PR(Closed)</a></p>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/054635a5-455a-4656-bf26-a527f51eeca4/image.png" alt=""></p>
<blockquote>
<p>sheremet 형님 : 거기 바꾸면 breakChange 되니까 다른 방식으로 해결하세요. 저도 고민중임.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/09c76b86-bf7d-4b82-ba0e-fd206ab87e5a/image.png" alt=""></p>
<p>잠시 슬픔과 죄송함의 복합적인 감정을 음미하였다.
뭔가 내 부족한 지식으로 메인테이너 형님의 시간을 빼았아, 폐가 되었기 때문이었을까.
이번 피드백으로 또 새로운 것을 배웠지만, 그래도 죄송한 마음은 어쩔 수 없을 따름이다.</p>
<hr>
<h2 id="8-새-pr">8. 새 PR</h2>
<p>format 자체에서 판단하는 것이 아닌, runner의 formatTitle에서 정규표현식으로 %f를 추출하는 과정에서 해당 값이 -0이나 -NaN일 경우 %f를 -%f로 바꾸는 것. 이 방식이 모든 코드를 살펴보았을 때, 셰르멧 형님이 원하는대로 변경점은 최소화가 된다.</p>
<p>금요일 퇴근 후, 시간이 조금 있어 자기 전 PR을 완성하였다.</p>
<hr>
<h2 id="9-merge">9. Merge</h2>
<ol>
<li>기존 formatTitle에 대한 테스트가 존재하지 않았고</li>
<li>해당 함수는 외부로 export되지 않으며</li>
<li>format이 완성된 title을 suite 함수 내부에서 this로 접근하는 것이 불가능했다.</li>
</ol>
<p>그렇기에 회귀 테스트를 작성할 수 없어서, 방향성에 대한 피드백을 구했지만 스크린샷으로 올렸던 자체 테스트 + sheremet 형님이 직접 테스트로 확인하셨는지 그냥 merge 때리셨다.</p>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/afaaa3e9-6040-4827-925c-a75e9d9a2e81/image.png" alt=""></p>
<h4 id="prmerged"><a href="https://github.com/vitest-dev/vitest/pull/5806">&gt; PR(merged)</a></h4>
<hr>
<h2 id="release--vitest-v200-beta7">Release : Vitest v.2.0.0-beta.7</h2>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/95e2ac2b-0a07-48fc-bd3a-368eef22880c/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[useSyncExternalStore의 추상화 된 getSnapshot 구독 및 렌더링 알고리즘 동기화]]></title>
            <link>https://velog.io/@pengoose_dev/useSyncExternalStore</link>
            <guid>https://velog.io/@pengoose_dev/useSyncExternalStore</guid>
            <pubDate>Tue, 16 Apr 2024 08:19:27 GMT</pubDate>
            <description><![CDATA[<p>useSyncExternalStore의 첫 번째 매개변수는 getSnapshot이라는 콜백함수를 전달받는다. 상태의 변경을 구독하고자 하는 state를 전달해야 한다.</p>
<pre><code class="language-ts">import { useSyncExternalStore } from &#39;react&#39;;

const externalState = { value: 42 };
const listeners = new Set();

const getSnapshot = () =&gt; {
  return externalState.value;
};

const subscribe = (listener: () =&gt; void) =&gt; {
  listeners.add(listener);
  return () =&gt; {
    listeners.delete(listener);
  };
};

const Component = () =&gt; {
  const state = useSyncExternalStore(subscribe, getSnapshot);
  return &lt;div&gt;Current value: {state}&lt;/div&gt;;
}</code></pre>
<p>하지만, 위 방식에서 <code>객체 내부에서 복잡한 비즈니스 로직을 추상화</code> 하는 경우. <strong>하나의 문제점</strong>이 발생한다.</p>
<hr>
<h2 id="state가-너무-많은-프로퍼티들을-가질-수-있다">state가 너무 많은 프로퍼티들을 가질 수 있다.</h2>
<p>React에서 제공하는 useSyncExternalStore가 state 자체를 구독하는 방식은 굉장히 직관적이다. 하지만 사용하고자 하는 모든 값을 모두 state로 추상화해야 한다는 문제가 있다.
 예를들어, 현재 유저가 재생중인 음악을 알고자한다면 playlist와 index 두 개의 state로 원하는 값을 얻을 수 있지만, useExternalStore의 경우 state에 currentMusic이라는 필드를 추상화해야 한다.</p>
<hr>
<h2 id="해결책1-추상화-된-값-반환">해결책1 (추상화 된 값 반환)</h2>
<p>이러한 문제는 getSnapshot 콜백함수가 state 그 자체를 반환하는 것이 아닌 <code>추상화 된 상태를 반환하는 방식</code>으로 해결할 수 있다.</p>
<pre><code class="language-ts">// 이해를 돕기 위한 의사 코드
const playerState = {
    playlist: Music[];
    index: number;
}

export abstract class StateManager&lt;T&gt; {
  constructor(protected state: T) {}

  public selectors = {
    playlist: this.state.playlist,
    currentMusic: this.state.playlist[this.state.index],
  };

  // ... codes
  public getSnapshot() {
    return this.selectors; // ⛳️ this.state가 아닌 추상화된 selectors 반환
  }
}</code></pre>
<hr>
<h2 id="문제점-1-고정된-메모리-주소">문제점 1: 고정된 메모리 주소</h2>
<p>하지만 해결책 1의 방식은 useSyncExternalStore의 <strong>re-rendering 알고리즘에 동기화되지 못한다.</strong> <code>내부 state가 변경되어도 selectors의 힙 메모리 주소 자체는 변경되지 않기 때문</code>이다.</p>
<hr>
<h2 id="해결책-2-변경된-메모리-주소-반환">해결책 2: 변경된 메모리 주소 반환</h2>
<p>selectors의 값들이 state를 그대로 구독하는 것이 아니라 호출 시점에서 변경된 state를 반환하도록 한다.</p>
<pre><code class="language-ts">class Playlist extends StateManager&lt;PlaylistStatus&gt; {
  selectors = {
    playlist: () =&gt; this.state.playlist,
    currentMusic: () =&gt; this.state.playlist[this.state.index],
  };</code></pre>
<hr>
<h2 id="문제점-2-re-render-알고리즘과-동기화되지-않음">문제점 2: re-render 알고리즘과 동기화되지 않음</h2>
<p> 하지만 여전히 getSnapshot의 코드를 변경하지 않는다면 문제점1에서 다룬 본질적인 문제는 해결되지 않는다. selectors 프로퍼티들의 함수 자체의 메모리 주소는 변경되지 않기 때문이다. </p>
<p>이쯤에서 <code>추상화된 코드</code>에 대해 살펴보자.</p>
<h3 id="언제-re-renderforceupdate되는가">언제 re-render(forceUpdate)되는가?</h3>
<p> 아래는 useSyncExternalStore의 구현체이다. re-render의 로직은 react-hook-form의 비제어 컴포넌트를 제어 컴포넌트로 동기화하는 로직과 비슷하다.</p>
<pre><code class="language-ts">export function useSyncExternalStore&lt;T&gt;(
  subscribe: (() =&gt; void) =&gt; () =&gt; void,
  getSnapshot: () =&gt; T,
  getServerSnapshot?: () =&gt; T,
): T {
  // ...codes
  const value = getSnapshot();
  const [{inst}, forceUpdate] = useState({inst: {value, getSnapshot}}); // ⛳️

  // ⛳️
  useLayoutEffect(() =&gt; {
    inst.value = value;
    inst.getSnapshot = getSnapshot;
    if (checkIfSnapshotChanged(inst)) {
      forceUpdate({inst});
    }
  }, [subscribe, value, getSnapshot]);

  // ⛳️
  useEffect(() =&gt; {
    // Check for changes right before subscribing. Subsequent changes will be
    if (checkIfSnapshotChanged(inst)) {
      // Force a re-render.
      forceUpdate({inst}); 
    }

    const handleStoreChange = () =&gt; {
      if (checkIfSnapshotChanged(inst)) {
        // Force a re-render.
        forceUpdate({inst});
      }
    };
    return subscribe(handleStoreChange);
  }, [subscribe]);

  useDebugValue(value);
  return value;
}

function checkIfSnapshotChanged&lt;T&gt;(inst: {
  value: T,
  getSnapshot: () =&gt; T,
}): boolean {
  const latestGetSnapshot = inst.getSnapshot;
  const prevValue = inst.value; // ⛳️
  try {
    const nextValue = latestGetSnapshot(); // ⛳️
    return !is(prevValue, nextValue); // ⛳️
  } catch (error) {
    return true;
  }
}</code></pre>
<pre><code class="language-ts">function is(x: any, y: any) {
  return (
    (x === y &amp;&amp; (x !== 0 || 1 / x === 1 / y)) || (x !== x &amp;&amp; y !== y) 
  );
}

const objectIs: (x: any, y: any) =&gt; boolean =
  typeof Object.is === &#39;function&#39; ? Object.is : is;  // ⛳️

export default objectIs;</code></pre>
<p>  useSyncExternalStore의 diff 알고리즘은 하나의 state를 구독하도록 추상화 되어있다. 따라서 Object.is(objetIs)를 사용하여 re-rendering의 여부를 결정하며, 해당 알고리즘을 재사용하기는 어렵다. <code>필자가 추상화 한 selector의 값들은 함수가 반환하는 객체이기 때문에 메모리 주소가 계속 변경되기 때문</code>이다.</p>
<hr>
<h2 id="해결책-2-deepequal">해결책 2: deepEqual</h2>
<p> 따라서, getSnapshot 내부에서 <code>deepEqual을 추상화하여 실질적인 값의 변화가 발생한 경우, 조건부로 값을 반환</code>해야 한다.</p>
<blockquote>
</blockquote>
<ul>
<li>휴리스틱 알고리즘을 학습하던 도중, state의 depth가 보통 크기 않기 때문에 득보단 실이 많다고 판단하여 간단하게 추상화한 뒤, 계속해서 발전시키기로 했다.</li>
</ul>
<h3 id="deepequal">deepEqual</h3>
<pre><code class="language-ts">const deepEqual = (obj1: any, obj2: any, cache = new WeakMap()): boolean =&gt; {
  const isSameValue = obj1 === obj2;
  if (isSameValue) {
    return true;
  }

  const isSameType = typeof obj1 === typeof obj2;
  if (!isSameType) {
    return false;
  }

  const isEitherNull = obj1 === null || obj2 === null;
  if (isEitherNull) {
    return false;
  }

  const isEitherObject = typeof obj1 === &#39;object&#39; &amp;&amp; typeof obj2 === &#39;object&#39;;
  if (!isEitherObject) {
    return false;
  }

  const isCircularReference = cache.has(obj1) &amp;&amp; cache.get(obj1) === obj2;
  if (isCircularReference) {
    return true;
  }

  const obj1Keys = Object.keys(obj1);
  const obj2Keys = Object.keys(obj2);

  const isSameLength = obj1Keys.length === obj2Keys.length;
  if (!isSameLength) {
    return false;
  }

  const obj2KeysSet = new Set(obj2Keys);
  cache.set(obj1, obj2);

  for (const key of obj1Keys) {
    if (!obj2KeysSet.has(key) || !deepEqual(obj1[key], obj2[key], cache)) {
      return false;
    }
  }

  return true;
};</code></pre>
<hr>
<h2 id="적용">적용</h2>
<h3 id="statemanager">stateManager</h3>
<pre><code class="language-ts">import { deepEqual } from &#39;@/utils/deepEqual&#39;;

export abstract class StateManager&lt;T&gt; {
  public listeners: Set&lt;() =&gt; void&gt; = new Set();

  private lastSnapshot: any = null;

  constructor(protected state: T) {}

  protected computeSnapshot(): StateManager&lt;T&gt;[&#39;selectors&#39;] {
    const selectors = Object.fromEntries(
      Object.entries(this.selectors).map(([key, selectorFn]) =&gt; {
        const getSelector = selectorFn as () =&gt; any;
        const selector = getSelector();

        return [key, selector];
      })
    ) as StateManager&lt;T&gt;[&#39;selectors&#39;];

    return selectors;
  }

  public getSnapshot(): StateManager&lt;T&gt;[&#39;selectors&#39;] {
    const currentSnapshot = this.computeSnapshot();

    const isInitial = this.lastSnapshot === null;
    const isChanged = !deepEqual(this.lastSnapshot, currentSnapshot);

    if (isInitial || isChanged) this.lastSnapshot = currentSnapshot;

    return this.lastSnapshot;
  }

  public set(newState: Partial&lt;T&gt;) {
    this.state = { ...this.state, ...newState };
    this.listeners.forEach((listener) =&gt; listener());
  }

  public subscribe(listener: () =&gt; void): () =&gt; void {
    this.listeners.add(listener);
    return () =&gt; this.listeners.delete(listener);
  }

  abstract selectors: {
    [K in keyof Partial&lt;T&gt;]: any;
  };

  abstract actions: {
    [key: string]: (payload: any) =&gt; void;
  };
}</code></pre>
<h3 id="usemanager">useManager</h3>
<pre><code class="language-ts">import { useSyncExternalStore } from &#39;react&#39;;
import { StateManager } from &#39;./stateManager&#39;;

export const useManager = &lt;T extends StateManager&lt;any&gt;&gt;(manager: T) =&gt; {
  const selectors = useSyncExternalStore(
    manager.subscribe.bind(manager),
    manager.getSnapshot.bind(manager)
  ) as {
    [K in keyof T[&#39;selectors&#39;]]: ReturnType&lt;T[&#39;selectors&#39;][K]&gt;;
  };

  const actions = manager.actions as {
    [K in keyof T[&#39;actions&#39;]]: T[&#39;actions&#39;][K];
  };

  return { selectors, actions };
};</code></pre>
<h3 id="적용-예시playlist">적용 예시(Playlist)</h3>
<pre><code class="language-ts">import { PlaylistStatus } from &#39;@/types/playlist&#39;;
import { StateManager } from &#39;./stateManager&#39;;
import { Music, PlaylistProps } from &#39;@/types&#39;;

class Playlist extends StateManager&lt;PlaylistStatus&gt; {
  selectors = {
    playlist: () =&gt; this.state.playlist,
    currentMusic: () =&gt; this.state.playlist[this.state.index],
  };

  actions = {
    play: (index: number) =&gt; {
      this.set({ index });
    },

    next: () =&gt; {
      const { playlist, index } = this.state;
      this.set({ index: this.getIncresedIndex({ index, playlist }) });
    },

    prev: () =&gt; {
      const { playlist, index } = this.state;
      this.set({ index: this.getDecresedIndex({ index, playlist }) });
    },

    add: (music: Music) =&gt; {
      const { playlist } = this.state;

      if (playlist.some(({ id }) =&gt; id === music.id)) return;

      this.set({ playlist: [...playlist, music] });
    },

    remove: (music: Music) =&gt; {
      const { playlist, index } = this.state;
      const targetIndex = playlist.findIndex((item) =&gt; item.id === music.id);
      const isTargetAfterCurrent = targetIndex &gt; index;

      const newPlaylist = playlist.filter((item) =&gt; item.id !== music.id);
      const newIndex = isTargetAfterCurrent
        ? index
        : this.getDecresedIndex({ index, playlist });

      this.set({
        playlist: newPlaylist,
        index: newIndex,
      });
    },
  };

  private isEmpty(playlist: Music[]) {
    return playlist.length === 0;
  }

  private isFirstMusic(index: number) {
    return index === 0;
  }

  private isLastMusic({ index, playlist }: PlaylistProps) {
    return index === playlist.length - 1;
  }

  private getIncresedIndex({ index, playlist }: PlaylistProps) {
    const isLastMusic = this.isLastMusic({ index, playlist });
    if (isLastMusic) return 0;

    return index + 1;
  }

  private getDecresedIndex({ index, playlist }: PlaylistProps) {
    const isEmpty = this.isEmpty(playlist);
    if (isEmpty) return 0;

    const isFirstMusic = this.isFirstMusic(index);
    if (isFirstMusic) return playlist.length - 1;

    return index - 1;
  }
}

const initialState: PlaylistStatus = {
  playlist: [],
  index: 0,
};

export const playlistManager = new Playlist(initialState);</code></pre>
<p>jotai에서 사용했던 도메인 별로 상태를 작게 쪼개, DI를 사용하는 방식도 추후에 구현해볼 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[React 안티패턴 파훼하기] 커링을 이용한 콜백함수 내부의 hook 실행순서 보장]]></title>
            <link>https://velog.io/@pengoose_dev/React-%EC%95%88%ED%8B%B0%ED%8C%A8%ED%84%B4-%ED%8C%8C%ED%9B%BC%ED%95%98%EA%B8%B0-%EC%BB%A4%EB%A7%81%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%BD%9C%EB%B0%B1%ED%95%A8%EC%88%98-%EB%82%B4%EB%B6%80%EC%9D%98-hook-%EC%8B%A4%ED%96%89%EC%88%9C%EC%84%9C-%EB%B3%B4%EC%9E%A5</link>
            <guid>https://velog.io/@pengoose_dev/React-%EC%95%88%ED%8B%B0%ED%8C%A8%ED%84%B4-%ED%8C%8C%ED%9B%BC%ED%95%98%EA%B8%B0-%EC%BB%A4%EB%A7%81%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%BD%9C%EB%B0%B1%ED%95%A8%EC%88%98-%EB%82%B4%EB%B6%80%EC%9D%98-hook-%EC%8B%A4%ED%96%89%EC%88%9C%EC%84%9C-%EB%B3%B4%EC%9E%A5</guid>
            <pubDate>Sun, 24 Mar 2024 18:22:29 GMT</pubDate>
            <description><![CDATA[<h1 id="들어가기-전-컨텍스트-이해하기">들어가기 전, 컨텍스트 이해하기.</h1>
<h3 id="1-하나의-view-model은-atommanager라는-추상클래스을-상속받는다">1. 하나의 View Model은 AtomManager라는 추상클래스을 상속받는다.</h3>
<p>이는 상태관리 컨벤션을 확립하기 위함이다.
구현체는 아래와 같다.</p>
<pre><code class="language-ts">import { Atom, WritableAtom, atom } from &#39;jotai&#39;;

export abstract class AtomManager&lt;T&gt; {
  public initialState: T;
  protected atom: WritableAtom&lt;T, any, void&gt;;

  constructor(initialState: T) {
    this.initialState = initialState;
    this.atom = atom(this.initialState);
  }

  abstract selectors: {
    [K in keyof Partial&lt;T&gt;]: Atom&lt;T[K]&gt;;
  };

  abstract actions: {
    [key: string]: WritableAtom&lt;T | null, any, void&gt;;
  };
}</code></pre>
<p>각 selectors는 getter atom을 프로퍼티로 갖는다.
각 actions는 setter atom을 프로퍼티로 갖는다.</p>
<h4 id="예시">예시</h4>
<pre><code class="language-tsx">
class CartManager extends AtomManager&lt;Cart&gt; {
  constructor(initialState: Cart) {
    super(initialState);
  }

  /* Selectors */
  public selectors = {
    items: atom((get) =&gt; get(this.atom).items),
  };

  /* Actions */
  public actions = {
    add: atom(
      null,
      (get, set, { amount, product }: { amount: number; product: Product }) =&gt; {
        const { items } = get(this.atom);
        // ...codes
      }
    ),

    delete: atom(null, (get, set, product: Product) =&gt; {
      const { items } = get(this.atom);
      // ...codes
    }),
  };
}

const initialData: Cart = {
  items: [],
};

export const cartManager = new CartManager(initialData);</code></pre>
<hr>
<h3 id="2-고차함수를-이용해-atom을-hook으로-래핑하고-타입을-동적으로-추론한다">2. 고차함수를 이용해 Atom을 hook으로 래핑하고 타입을 동적으로 추론한다.</h3>
<p>useManager은 두 가지 역할을 수행해야한다.</p>
<blockquote>
<ol>
<li>추상클래스로 추상화한 field(selectors, actions)가 가진 <strong>method Type들을 동적으로 추론해 반환</strong>해야한다.</li>
<li>atom을 useAtomValue 등의 훅을 사용하여 state로 변환해야 한다.</li>
</ol>
</blockquote>
<pre><code class="language-ts">export const useManager = &lt;T extends AtomManager&lt;any&gt;&gt;(manager: T) =&gt; {
  const selectors = Object.fromEntries(
    Object.entries(manager.selectors).map(([key, atom]) =&gt; [
      key,
      useAtomValue(atom),
    ])
  ) as {
    [P in keyof T[&#39;selectors&#39;]]: T[&#39;selectors&#39;][P] extends Atom&lt;infer V&gt;
      ? V
      : never;
  };

  const actions = Object.fromEntries(
    Object.entries(manager.actions).map(([key, actionAtom]) =&gt; [
      key,
      useSetAtom(actionAtom),
    ])
  ) as {
    [P in keyof T[&#39;actions&#39;]]: T[&#39;actions&#39;][P] extends WritableAtom&lt;
      any,
      infer U,
      void
    &gt;
      ? (param: U[0]) =&gt; void
      : never;
  };

  return { selectors, actions };
};</code></pre>
<hr>
<h1 id="문제점-발생---callback-함수-내부에서-커스텀훅-호출">문제점 발생 - Callback 함수 내부에서 커스텀훅 호출</h1>
<p>현재 코드는 콜백함수 내부에서 커스텀훅을 호출하여, hook의 호출 순서를 보장받지 못한다.</p>
<pre><code class="language-ts">export const useManager = &lt;T extends AtomManager&lt;any&gt;&gt;(manager: T) =&gt; {
  const selectors = Object.fromEntries(
    Object.entries(manager.selectors).map(([key, atom]) =&gt; [
      key,
      useAtomValue(atom), // 🚩 Error : Callback 내부에서 커스텀훅 호출
    ])
  ) as {
    [P in keyof T[&#39;selectors&#39;]]: T[&#39;selectors&#39;][P] extends Atom&lt;infer V&gt;
      ? V
      : never;
  };

  const actions = Object.fromEntries(
    Object.entries(manager.actions).map(([key, actionAtom]) =&gt; [
      key,
      useSetAtom(actionAtom), // 🚩 Error : Callback 내부에서 커스텀훅 호출
    ])
  ) as {
    [P in keyof T[&#39;actions&#39;]]: T[&#39;actions&#39;][P] extends WritableAtom&lt;
      any,
      infer U,
      void
    &gt;
      ? (param: U[0]) =&gt; void
      : never;
  };

  return { selectors, actions };
};</code></pre>
<p>커스텀훅은 최상위 Layer에서만 호출되어야한다.
Proxy를 써서 파훼를 시도했지만 역시 실패하였다.</p>
<hr>
<h1 id="해결--또-다시-고차함수커링">해결 : 또 다시 고차함수(커링)</h1>
<p>고차함수는 답을 알고있다. 커링을 응용해 props를 전달하는 함수를 반환하는 고차함수를 작성해주자.
최상위 layer에서 Hook을 사용할 수 있도록 함수로 래핑하여 반환해주자.</p>
<pre><code class="language-tsx">import { Atom, WritableAtom, useAtomValue, useSetAtom } from &#39;jotai&#39;;
import { AtomManager } from &#39;@/Model/manager/atomManager&#39;;

const createUseSelector = &lt;T&gt;(atom: Atom&lt;T&gt;) =&gt; {
  return () =&gt; useAtomValue(atom); // ✅
};

const createUseAction = (atom: WritableAtom&lt;any, any, void&gt;) =&gt; {
  return () =&gt; useSetAtom(atom); // ✅
};

export const useManager = &lt;T extends AtomManager&lt;any&gt;&gt;(manager: T) =&gt; {
  const selectors = Object.fromEntries(
    Object.entries(manager.selectors).map(([key, atom]) =&gt; [
      key,
      createUseSelector(atom)(), // ✅
    ])
  ) as {
    [P in keyof T[&#39;selectors&#39;]]: T[&#39;selectors&#39;][P] extends Atom&lt;infer V&gt;
      ? V
      : never;
  };

  const actions = Object.fromEntries(
    Object.entries(manager.actions).map(([key, actionAtom]) =&gt; [
      key,
      createUseAction(actionAtom)(), // ✅
    ])
  ) as unknown as {
    [P in keyof T[&#39;actions&#39;]]: T[&#39;actions&#39;][P] extends WritableAtom&lt;
      any,
      infer U,
      void
    &gt;
      ? (param: U[0]) =&gt; void
      : never;
  };

  return { selectors, actions };
};
</code></pre>
<p>개선하여 @pengoose/jotai V1.1.4 배포! 😆👍</p>
<hr>
<h2 id="thxto">ThxTo</h2>
<ul>
<li>고차함수 함수 및 고차 컴포넌트 설계에 통찰력을 주신 <strong><code>NextStep 그리고 소인성님</code></strong> 감사합니다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Line 오픈소스 기여 과정 살펴보기]]></title>
            <link>https://velog.io/@pengoose_dev/Line-%EC%98%A4%ED%94%88%EC%86%8C%EC%8A%A4-%EA%B8%B0%EC%97%AC-%EA%B3%BC%EC%A0%95.-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EB%B0%B0%EC%9A%B4-%EA%B2%83</link>
            <guid>https://velog.io/@pengoose_dev/Line-%EC%98%A4%ED%94%88%EC%86%8C%EC%8A%A4-%EA%B8%B0%EC%97%AC-%EA%B3%BC%EC%A0%95.-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EB%B0%B0%EC%9A%B4-%EA%B2%83</guid>
            <pubDate>Sat, 09 Mar 2024 08:58:42 GMT</pubDate>
            <description><![CDATA[<p> 지인분이 <code>GDG 오픈소스 프로젝트</code>에서 Google 오픈소스에 기여하신 뒤 컨퍼런스에서 발표를 하셨다.
그 분의 추천으로 인제님이 이끄시는 GDG Songdo 오픈소스 프로젝트 3기에 참여하게 되었다.</p>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/9b0d3e9b-ee61-4882-b876-8f971ab2ce19/image.png" alt=""></p>
<blockquote>
<p>이 글은 오픈소스를 처음으로 기여하는 개발자들에게 도움을 주고자, 스터디 및 오픈소스 기여 과정에서 얻은 지식들을 정리한 것이다.</p>
</blockquote>
<p>아래의 Flow를 따라가며 첫 오픈소스 기여를 시도해보자 🥳</p>
<hr>
<h1 id="1-issue-찾아보기">1) Issue 찾아보기</h1>
<p> PR을 할 수 있는 Issue를 찾는 방법에는 크게 2가지가 있다. </p>
<blockquote>
<h3 id="1-1-기존-issue를-할당받는-방법">1-1) 기존 Issue를 할당받는 방법</h3>
<p> Issue 카테고리에서 issue를 할당받는 방식이다. 
아래의 조건을 만족하는 경우 PR을 생성하기 좋은 Issue이다.</p>
</blockquote>
<pre><code class="language-shell">1. maintainer와 방향성의 토의가 이루어져있는 경우.
2. 문제가 발생하는 조건에 대해 상세하게 기술되어있는 경우.
3. 개선 방향성을 제시해둔 경우.
4. 히스토리 파악을 위한 커밋 및 PR을 남겨둔 경우.</code></pre>
<blockquote>
<h3 id="1-2-직접-issue를-생성하는-방법">1-2) 직접 Issue를 생성하는 방법</h3>
<p> 코드를 직접 훑어보며 개선점을 직접 제시하는 것도 좋다. 
 <img src="https://velog.velcdn.com/images/pengoose_dev/post/bc5f1aaa-9ca4-4933-9ec7-75805b426f20/image.png" alt=""></p>
</blockquote>
<h4 id="issue-699"><a href="https://github.com/line/line-bot-sdk-nodejs/issues/699">&gt; Issue #699</a></h4>
<blockquote>
<p>커스텀 에러에서 name field를 사용하지 않아, 이에 대한 개선을 제안하였다.</p>
</blockquote>
<hr>
<h1 id="2-이슈-할당받기">2) 이슈 할당받기</h1>
<p>Project Maintainer와 협의가 되었다면, Issue를 할당받게 된다. </p>
<ul>
<li>명시적으로 할당받지 않더라도 Maintainer와 다른 개발자들에게 적절하게 어필한 뒤, 시작하는 것도 좋아보인다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/b74d163b-240d-403f-b5b4-cda0c4f88711/image.png" alt=""></p>
<hr>
<h1 id="3-코드-컨텍스트-파악하기">3) 코드 컨텍스트 파악하기</h1>
<p> 코드를 작성하기 전, 해당 코드의 Context와 history를 파악해야한다. 필자는 아래의 방식으로 코드를 이해하였다.</p>
<blockquote>
</blockquote>
<ol>
<li>UnitTest가 구현되어있는 경우, 이를 기반으로 로직 이해하기.</li>
<li>commit history, PR을 확인하여 history 파악하기.</li>
<li>코드에 대한 의존성을 파악하기.</li>
</ol>
<p>위 사항들을 진행한 결과 변경해야할 부분과 추가적으로 작성해야할 테스트코드의 위치 및 컨벤션을 쉽게 익힐 수 있었다.</p>
<hr>
<h1 id="4-문제-해결하기">4) 문제 해결하기</h1>
<p>이제 코드를 작성할 시간이다.
아래의 것들을 주의하며 코드를 작성하였다.</p>
<blockquote>
</blockquote>
<ol>
<li>네이밍 컨벤션을 잘 지키고 있는가?</li>
<li>코드 컨벤션을 잘 지키고 있는가?</li>
<li>커밋 컨벤션을 잘 지키고 있는가?</li>
<li>이미 추상화 되어있는 것을 또 다시 추상화 하고 있지 않은가?(Type 포함)</li>
<li>테스트 코드나 문서도 함께 변경되어야 하는가?</li>
</ol>
<p>코드를 작성하다보면, <code>내가 이걸 바꿔도 되나?</code>라는 의문이 드는 부분이 발생하게 된다.
그럴땐...</p>
<hr>
<h1 id="5-피드백-받기">5) 피드백 받기</h1>
<p>조금이라도 애매한 부분은 Issue 및 PR에서 <strong><code>Maintainer와 함께 논의하는 것이 중요</code></strong>하다고 생각한다.</p>
<blockquote>
<h3 id="피드백-요청-예시-">피드백 요청 예시 :</h3>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/db88611b-d6a8-4110-b5f2-1094b1f61f0d/image.png" alt=""></p>
<p>물론, Maintainer에게 피드백을 받을 수 없는 경우, 과감한 변경은 삼가는 것이 좋아보인다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/cd25c884-2cb7-4750-be4e-88db85520fa4/image.png" alt=""></p>
<blockquote>
</blockquote>
<hr>
<h1 id="6-pr-생성하기">6) PR 생성하기</h1>
<p>PR을 생성할 때, 정해진 템플릿에 맞춰 PR을 생성한다.
정해진 템플릿이 없다면 이전 PR들을 확인하여 따르는 것이 편하다.</p>
<blockquote>
</blockquote>
<ol>
<li>PR Convention 맞추기(제목, 템플릿 등)</li>
<li>해당 PR이 어떤 Issue를 기반으로 생성된 PR인지 명시하기</li>
<li>해당 PR이 어떤 내용을 담고있는지 명시하기</li>
</ol>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/d98962ff-9f16-4efd-ab49-c492ae31ad5c/image.png" alt=""></p>
<blockquote>
</blockquote>
<h4 id="pr-739"><a href="https://github.com/line/line-bot-sdk-nodejs/pull/739#issuecomment-1985130119">&gt; PR [#739]</a></h4>
<hr>
<h1 id="7-contribute-필수-요소-확인하기">7) Contribute 필수 요소 확인하기</h1>
<p>또한, 변경사항이 merge되기 이전, 아래의 요소들을 확인해야한다.</p>
<blockquote>
</blockquote>
<ol>
<li>build 확인하기</li>
<li>테스트 통과 확인하기</li>
<li>CI test 확인하기</li>
<li>Contribute 요구사항 확인하기</li>
<li>권리 계약서 서명하기(요구하는 경우에만)</li>
</ol>
<p>보통 아래처럼 문서화 되어있는 경우가 대부분이다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/e406c666-a805-40c3-a76c-540bc3f2023d/image.png" alt=""></p>
<blockquote>
<p>Contributer에게 제공되는 문서가 있다면, 해당 문서를 잘 읽어보자!</p>
</blockquote>
<hr>
<h1 id="8-45-단계-반복-그리고-merge">8) 4,5 단계 반복. 그리고 merge</h1>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/5d7cc40b-8696-4c7e-b0fc-baef19ad9c6c/image.png" alt=""></p>
<p>필자의 경우 Maintainer분이 활발하게 피드백을 진행해주셨기에 수월하게 PR을 merge할 수 있었다.</p>
<p>Line에 관심이 많아 여러 프로젝트를 살펴봤었는데, 이렇게 기여까지 하게 되어 기쁠따름이다. 🥳</p>
<h4 id="issue"><a href="https://github.com/line/line-bot-sdk-nodejs/issues/699">&gt; Issue</a></h4>
<h4 id="prrefactor-error"><a href="https://github.com/line/line-bot-sdk-nodejs/pull/739">&gt; PR(Refactor Error)</a></h4>
<hr>
<h1 id="9-추가-issue-및-pr-생성하기">+9) 추가 Issue 및 PR 생성하기</h1>
<p>코드를 작성하다보니 UnitTest에 대한 개선 사항이 보여 추가적으로 개선을 제안했다.</p>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/9e923b6f-5e98-44fd-bc72-70adc574c811/image.png" alt=""></p>
<p>(Mocha에서는 test.each 메서드를 지원하지 않아 forEach를 사용)</p>
<p>빠르게 merge 되었다.</p>
<h4 id="prrefactor-unittest"><a href="https://github.com/line/line-bot-sdk-nodejs/pull/744#event-12098161744">&gt; PR(Refactor UnitTest)</a></h4>
<hr>
<h1 id="opensource-study">Opensource Study</h1>
<p>한국의 오픈소스 생태계를 확장하고자 하는 인제님이 이끄시는 스터디이다.
인제님과 팀원들이 모여 본인이 원하는 프로젝트의 issue를 살펴보고 이를 해결하는 스프린트가 1주 동안 진행된다.</p>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/e8509596-8eee-4473-b0b4-a7eee2d1b7c1/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/fe80d006-99fd-440a-8742-0e9f24d1f908/image.png" alt=""></p>
<hr>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/814e4ca5-a436-4ada-8440-a86a25766917/image.png" alt=""></p>
<p>스터디엔 참가비가 있다.
<strong><code>원하는 오픈 소스에 50달러를 Sponsor</code></strong>하는 것이다.</p>
<p>인제님 뿐 아니라 팀원들 전부 열정과 낭만이 넘친다는 것을 느꼈고, 굉장히 많은 인사이트를 얻어갈 수 있었다.</p>
<p>매달 꾸준히 하실 예정이라고 하신다. 다음달에도 참여하는걸로! 😆</p>
<p><img src="https://velog.velcdn.com/images/pengoose_dev/post/98bbf1bd-b2d9-481d-9b98-e02aefe15002/image.png" alt=""></p>
<h3 id="인제님의-오픈소스-블로그"><a href="https://medium.com/opensource-contributors/%EC%98%A4%ED%94%88%EC%86%8C%EC%8A%A4-%EB%A9%98%ED%86%A0%EB%A7%81-%EC%86%8C%EA%B0%9C-%EC%9A%B0%EB%A6%AC%EA%B0%80-%ED%95%9C%EB%8B%AC%EB%A7%8C%EC%97%90-k8s-spring-resilience4j-%EB%93%B1-%EC%9B%90%ED%95%98%EB%8A%94-%EC%98%A4%ED%94%88%EC%86%8C%EC%8A%A4%EC%97%90-%ED%95%A8%EA%BB%98-%EA%B8%B0%EC%97%AC%ED%95%A0-%EC%88%98-%EC%9E%88%EC%97%88%EB%8D%98-%EB%B0%A9%EB%B2%95-d1ed1a802247">&gt; 인제님의 오픈소스 블로그</a></h3>
<h3 id="gdg-songdo-오픈소스-톡방"><a href="https://open.kakao.com/o/ghrD0mUf">&gt; GDG Songdo 오픈소스 톡방</a></h3>
<p>🥳👍</p>
]]></description>
        </item>
    </channel>
</rss>