# 6. [Microsoft / playwright] fix(expect): adjust normalization for regex values in toHaveText matcher

pengooseDev·2024년 11월 13일
0
post-thumbnail

1. Issue

issue #29382

toHaveText 매개변수로 정규표현식 담긴 배열 넘기면 맞아도 틀렸다 뜨는 문제.

쉬워보이는데 왜 열린지 9개월째 해결이 안됐을까?
아래의 사진(번역)이 그 이유를 알려준다.

diff의 message를 print하는 로직은 외부 유틸함수(jest)에 위임된 상태여서 내부 diff 로직이 사용자에게 커스텀 matcher(diff로직) API를 제공하지 않는다면 해결하기 어려운 문제라고 언급해서 그런듯하다.

필자는 vitest쪽에서 비대칭 매처쪽 코드를 많이 봤었기 때문에, 이번에도 일단 헤딩 시작!


2. Context 파악

  1. 코드보기
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, 'toHaveText', locator, 'Locator', async (isNot, timeout) => {
      const expectedText = serializeExpectedTextValues(expected, { normalizeWhiteSpace: true, ignoreCase: options.ignoreCase });
      return await locator._expect('to.have.text.array', { expectedText, isNot, useInnerText: options?.useInnerText, timeout });
    }, expected, options);
  } else {
    return toMatchText.call(this, 'toHaveText', locator, 'Locator', async (isNot, timeout) => {
      const expectedText = serializeExpectedTextValues([expected], { normalizeWhiteSpace: true, ignoreCase: options.ignoreCase });
      return await locator._expect('to.have.text', { expectedText, isNot, useInnerText: options?.useInnerText, timeout });
    }, expected, options);
  }
}
  1. toEqual 확인
export async function toEqual<T>(
  this: ExpectMatcherState,
  matcherName: string,
  receiver: Locator,
  receiverType: string,
  query: (isNot: boolean, timeout: number) => Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>,
  expected: T,
  options: { timeout?: number, contains?: boolean } = {},
): Promise<MatcherResult<any, any>> {
  expectTypes(receiver, [receiverType], matcherName);

  const matcherOptions = {
    comment: options.contains ? '' : 'deep equality',
    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: () => '',
      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 = () => {
    const header = matcherHint(this, receiver, matcherName, 'locator', 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,
  };
}

3. 회귀 테스트 추가

이슈 내용 그대로 매개변수로 (string|RegExp)[] 넘겨줬을 때 regex가 match될 때 에러 안던지면 됨.

test('should only highlight unmatched regex in diff message for toHaveText with array', async ({ runInlineTest }) => {
  const result = await runInlineTest({
    'a.spec.ts': `
      import { test, expect } from '@playwright/test';
      test('toHaveText with mixed strings and regexes (array)', async ({ page }) => {
        await page.setContent(\`
          <ul>
            <li>Coffee</li>
            <li>Tea</li>
            <li>Milk</li>
          </ul>
        \`);
        const items = page.locator('li');
        await expect(items).toHaveText(['Coffee', /\\d+/, /Milk/]);
      });
    `,
  });
  expect(result.exitCode).toBe(1);
  const output = result.output;
  expect(output).toContain('-   /\\d+/,'); // 숫자값에 match되지 않았으니 에러 메시지에 있어야 함.
  expect(output).toContain('+   "Tea",'); // 위 regex랑 페어. 문자열이니 에러 메시지에 있어야 함.
  // 해당 유닛테스트의 본질. 내 코드가 있을때만, 아래의 테스트가 통과해야함.
  expect(output).not.toContain('-   /Milk/,'); // regex에 match되니 에러 메시지에 없어야 함.
  expect(output).not.toContain('-   "Coffee",'); // 그냥 기본으로 통과되어야 함.
});

4. 해결

if (pass) {
    printedExpected = `Expected: not ${this.utils.printExpected(expected)}`;
    printedReceived = `Received: ${this.utils.printReceived(received)}`;
  // ⛳️ 1. 배열 쌍인 경우 중,
  // (제네릭 타입이기 때문에 (string | RegExp)[]가 아닐 수 있으므로 분기처리)
  } else if (Array.isArray(expected) && Array.isArray(received)) {
    const normalizedExpected = expected.map((exp, index) => {
      const rec = received[index];
      // ⛳️ 2. received 값이 RegExp인 경우
      if (isRegExp(exp))
        // ⛳️ 3. expect의 값을 receive와 동일하게 변경
        // 커스텀 플래그("[match]")를 세울까 하다가 원본 값을 최대한 유지(rec => 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,
    );
  }

5. PR 및 merged

간단한 변경사항 요청 후 merged

> PR #33533

0개의 댓글