OSSCA Chromium

기여 목록 / 8045801

[security] Render the SAN with Lit

DevToolsrefactordevtools/devtools-frontendMerged

SecurityOriginView의 SAN 표시를 Lit으로 전환했습니다. devtools-button에 accessibleExpanded 속성을 추가하고, createSanDiv는 기존 UI와 연결하는 함수로 유지했습니다.

문제 설명

SecurityOriginView는 인증서의 SAN(Subject Alternative Name) 목록을 DOM 생성·수정 코드로 구성하고, 목록을 펼치거나 접을 때 클래스와 버튼 상태를 직접 변경하고 있었습니다. 이 부분을 Lit 템플릿으로 전환하는 작업입니다.

해결 내용

  • renderSan()에서 SAN 항목과 펼치기·접기 버튼을 렌더링하도록 했습니다. 목록이 비어 있으면 (n/a)를 표시하고, 3개 이하면 모두 표시하며, 4개 이상이면 처음에는 2개만 표시하는 기존 동작을 유지했습니다.
  • #createSanDiv()에서 접힘 상태를 관리하고, 버튼을 누르면 상태를 바꿔 다시 렌더링하도록 했습니다. 반환한 요소는 기존 UI에서 사용합니다.
  • 버튼을 devtools-button으로 전환하고, accessibleExpanded 속성으로 내부 버튼의 aria-expanded를 설정할 수 있도록 했습니다.
  • SAN 표시 테스트 3개와 버튼의 aria-expanded 테스트 2개를 추가하고, Security 패널의 빌드 설정에 버튼 컴포넌트 의존성을 추가했습니다.

전체 코드 diff는 다음과 같습니다.

diff --git a/front_end/panels/security/BUILD.gn b/front_end/panels/security/BUILD.gn
index 6a4b3ee..fb0e1d7 100644
--- a/front_end/panels/security/BUILD.gn
+++ b/front_end/panels/security/BUILD.gn
@@ -35,6 +35,7 @@
     "../../generated",
     "../../generated:protocol",
     "../../panels/network/forward:bundle",
+    "../../ui/components/buttons:bundle",
     "../../ui/kit:bundle",
     "../../ui/legacy:bundle",
     "../../ui/lit:bundle",
diff --git a/front_end/panels/security/SecurityPanel.test.ts b/front_end/panels/security/SecurityPanel.test.ts
index 6cc4310..3038f57 100644
--- a/front_end/panels/security/SecurityPanel.test.ts
+++ b/front_end/panels/security/SecurityPanel.test.ts
@@ -8,6 +8,7 @@
 import * as Platform from '../../core/platform/platform.js';
 import * as SDK from '../../core/sdk/sdk.js';
 import * as Protocol from '../../generated/protocol.js';
+import {renderElementIntoDOM} from '../../testing/DOMHelpers.js';
 import {createTarget, describeWithEnvironment} from '../../testing/EnvironmentHelpers.js';
 import {getMainFrame, navigate} from '../../testing/ResourceTreeHelpers.js';

@@ -41,6 +42,85 @@
   });
 });

+describeWithEnvironment('SecurityOriginView', () => {
+  function createOriginState(sanList: string[]): Security.SecurityPanel.OriginState {
+    return {
+      securityState: Protocol.Security.SecurityState.Secure,
+      securityDetails: {
+        protocol: 'TLS 1.3',
+        keyExchange: '',
+        cipher: 'AES_128_GCM',
+        certificateId: 0 as Protocol.Security.CertificateId,
+        subjectName: 'example.com',
+        sanList,
+        issuer: 'Test CA',
+        validFrom: 0,
+        validTo: 1,
+        signedCertificateTimestampList: [],
+        certificateTransparencyCompliance: Protocol.Network.CertificateTransparencyCompliance.Unknown,
+        encryptedClientHello: false,
+      },
+      loadedFromCache: false,
+    };
+  }
+
+  it('renders an empty SAN', () => {
+    const view = new Security.SecurityPanel.SecurityOriginView(urlString`https://foo.bar`, createOriginState([]));
+
+    const sanElement = view.element.querySelector('.san');
+    assert.instanceOf(sanElement, HTMLElement);
+    assert.strictEqual(sanElement.textContent, '(n/a)');
+  });
+
+  it('renders a SAN without truncation button', () => {
+    const view = new Security.SecurityPanel.SecurityOriginView(urlString`https://foo.bar`,
+                                                               createOriginState(['a.test', 'b.test', 'c.test']));
+    renderElementIntoDOM(view);
+
+    const sanElement = view.element.querySelector('.san');
+    assert.instanceOf(sanElement, HTMLElement);
+
+    const sanEntries = [...sanElement.querySelectorAll<HTMLElement>('.san-entry')];
+    assert.deepEqual(sanEntries.map(entry => entry.textContent), ['a.test', 'b.test', 'c.test']);
+    assert.isTrue(sanEntries.every(entry => entry.checkVisibility()));
+
+    const truncationToggle = sanElement.querySelector('devtools-button');
+    assert.notExists(truncationToggle);
+  });
+
+  it('renders a SAN with truncation button and updates on truncation toggle', () => {
+    const view = new Security.SecurityPanel.SecurityOriginView(
+        urlString`https://foo.bar`, createOriginState(['a.test', 'b.test', 'c.test', 'd.test']));
+    renderElementIntoDOM(view);
+
+    const sanElement = view.element.querySelector('.san');
+    assert.instanceOf(sanElement, HTMLElement);
+
+    const visibleSanEntries = () => [...sanElement.querySelectorAll<HTMLElement>('.san-entry')]
+                                        .filter(entry => entry.checkVisibility())
+                                        .map(entry => entry.textContent);
+
+    assert.deepEqual(visibleSanEntries(), ['a.test', 'b.test']);
+
+    const toggleButton = sanElement.querySelector('devtools-button');
+    assert.instanceOf(toggleButton, HTMLElement);
+    assert.strictEqual(toggleButton.textContent, 'Show more (4 total)');
+    assert.isFalse(toggleButton.accessibleExpanded);
+
+    toggleButton.click();
+
+    assert.deepEqual(visibleSanEntries(), ['a.test', 'b.test', 'c.test', 'd.test']);
+    assert.strictEqual(toggleButton.textContent, 'Show less');
+    assert.isTrue(toggleButton.accessibleExpanded);
+
+    toggleButton.click();
+
+    assert.deepEqual(visibleSanEntries(), ['a.test', 'b.test']);
+    assert.strictEqual(toggleButton.textContent, 'Show more (4 total)');
+    assert.isFalse(toggleButton.accessibleExpanded);
+  });
+});
+
 describeWithEnvironment('SecurityPanelSidebarTree', () => {
   describe('updateOrigin', () => {
     it('correctly updates the URL scheme highlighting', () => {
diff --git a/front_end/panels/security/SecurityPanel.ts b/front_end/panels/security/SecurityPanel.ts
index 33779e1e..3513cc1 100644
--- a/front_end/panels/security/SecurityPanel.ts
+++ b/front_end/panels/security/SecurityPanel.ts
@@ -10,9 +10,10 @@
 import * as SDK from '../../core/sdk/sdk.js';
 import * as Protocol from '../../generated/protocol.js';
 import * as NetworkForward from '../../panels/network/forward/forward.js';
+import * as Buttons from '../../ui/components/buttons/buttons.js';
 import {createIcon, type Icon} from '../../ui/kit/kit.js';
 import * as UI from '../../ui/legacy/legacy.js';
-import {html, render, type TemplateResult} from '../../ui/lit/lit.js';
+import {Directives, html, nothing, render, type TemplateResult} from '../../ui/lit/lit.js';
 import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';

 import lockIconStyles from './lockIcon.css.js';
@@ -1311,6 +1312,39 @@
   }
 }

+const SAN_NUM_SHOWN_WHEN_TRUNCATED = 2;
+
+function renderSan(sanList: string[], isSanListTruncatable: boolean, isSanListTruncated: boolean,
+                   onToggleTruncation: () => void): TemplateResult {
+  if (sanList.length === 0) {
+    return html`<div class="san empty-san">${i18nString(UIStrings.na)}</div>`;
+  }
+
+  const toggleButtonText =
+      isSanListTruncated ? i18nString(UIStrings.showMoreSTotal, {PH1: sanList.length}) : i18nString(UIStrings.showLess);
+
+  // clang-format off
+  return html`
+    <div class=${Directives.classMap({ san: true, 'truncated-san': isSanListTruncated })}>
+      ${sanList.map((san, index) => {
+        return html`
+          <span class=${Directives.classMap({
+            'san-entry': true,
+            'truncated-entry': isSanListTruncatable && index >= SAN_NUM_SHOWN_WHEN_TRUNCATED,
+          })}>${san}</span>`;
+      })}
+      ${isSanListTruncatable ? html`
+        <devtools-button
+          .variant=${Buttons.Button.Variant.OUTLINED}
+          .accessibleLabel=${toggleButtonText}
+          .accessibleExpanded=${!isSanListTruncated}
+          .jslogContext=${'security.toggle-san-truncation'}
+          @click=${onToggleTruncation}>${toggleButtonText}
+        </devtools-button>` : nothing}
+    </div>`;
+  // clang-format on
+}
+
 export class SecurityOriginView extends UI.Widget.VBox {
   readonly #origin: Platform.DevToolsPath.UrlString;
   readonly #originDisplay: HTMLElement;
@@ -1415,7 +1449,7 @@
         UI.ARIAUtils.markAsHeading(sctDiv, 2);
       }

-      const sanDiv = this.createSanDiv(originState.securityDetails.sanList);
+      const sanDiv = this.#createSanDiv(originState.securityDetails.sanList);
       const validFromString = new Date(1000 * originState.securityDetails.validFrom).toUTCString();
       const validUntilString = new Date(1000 * originState.securityDetails.validTo).toUTCString();

@@ -1527,44 +1561,23 @@
     }
   }

-  private createSanDiv(sanList: string[]): Element {
-    const sanDiv = document.createElement('div');
-    if (sanList.length === 0) {
-      sanDiv.textContent = i18nString(UIStrings.na);
-      sanDiv.classList.add('empty-san');
-    } else {
-      const truncatedNumToShow = 2;
-      const listIsTruncated = sanList.length > truncatedNumToShow + 1;
-      for (let i = 0; i < sanList.length; i++) {
-        const span = sanDiv.createChild('span', 'san-entry');
-        span.textContent = sanList[i];
-        if (listIsTruncated && i >= truncatedNumToShow) {
-          span.classList.add('truncated-entry');
-        }
-      }
-      if (listIsTruncated) {
-        function toggleSANTruncation(): void {
-          const isTruncated = sanDiv.classList.contains('truncated-san');
-          let buttonText;
-          if (isTruncated) {
-            sanDiv.classList.remove('truncated-san');
-            buttonText = i18nString(UIStrings.showLess);
-          } else {
-            sanDiv.classList.add('truncated-san');
-            buttonText = i18nString(UIStrings.showMoreSTotal, {PH1: sanList.length});
-          }
-          truncatedSANToggle.textContent = buttonText;
-          UI.ARIAUtils.setLabel(truncatedSANToggle, buttonText);
-          UI.ARIAUtils.setExpanded(truncatedSANToggle, isTruncated);
-        }
-        const truncatedSANToggle = UI.UIUtils.createTextButton(
-            i18nString(UIStrings.showMoreSTotal, {PH1: sanList.length}), toggleSANTruncation,
-            {jslogContext: 'security.toggle-san-truncation'});
-        sanDiv.appendChild(truncatedSANToggle);
-        toggleSANTruncation();
-      }
-    }
-    return sanDiv;
+  #createSanDiv(sanList: string[]): Element {
+    const container = document.createElement('div');
+    const isSanListTruncatable = sanList.length > SAN_NUM_SHOWN_WHEN_TRUNCATED + 1;
+    let isSanListTruncated = isSanListTruncatable;
+
+    const onToggleTruncation = (): void => {
+      isSanListTruncated = !isSanListTruncated;
+      updateSan();
+    };
+
+    const updateSan = (): void => {
+      // eslint-disable-next-line @devtools/no-lit-render-outside-of-view
+      render(renderSan(sanList, isSanListTruncatable, isSanListTruncated, onToggleTruncation), container);
+    };
+
+    updateSan();
+    return container;
   }

   setSecurityState(newSecurityState: Protocol.Security.SecurityState): void {
diff --git a/front_end/ui/components/buttons/Button.test.ts b/front_end/ui/components/buttons/Button.test.ts
index 8c536f5..3f4ab4c 100644
--- a/front_end/ui/components/buttons/Button.test.ts
+++ b/front_end/ui/components/buttons/Button.test.ts
@@ -142,6 +142,26 @@
     assert.strictEqual(innerButton.title, 'Custom2');
   });

+  it('sets aria-expanded on the internal button', () => {
+    const button = renderButton({
+      variant: Buttons.Button.Variant.PRIMARY,
+      accessibleExpanded: false,
+    });
+    const innerButton = button.shadowRoot?.querySelector('button');
+    assert.isOk(innerButton);
+    assert.strictEqual(innerButton.getAttribute('aria-expanded'), 'false');
+
+    button.accessibleExpanded = true;
+    assert.strictEqual(innerButton.getAttribute('aria-expanded'), 'true');
+  });
+
+  it('does not set aria-expanded on the internal button when accessibleExpanded is not provided', () => {
+    const button = renderButton();
+    const innerButton = button.shadowRoot?.querySelector('button');
+    assert.isOk(innerButton);
+    assert.isNull(innerButton.getAttribute('aria-expanded'));
+  });
+
   it('gets the text-with-icon class set for the inner button if text and icon is provided', () => {
     const button = renderButton(
         {
diff --git a/front_end/ui/components/buttons/Button.ts b/front_end/ui/components/buttons/Button.ts
index a54836a..d984b45 100644
--- a/front_end/ui/components/buttons/Button.ts
+++ b/front_end/ui/components/buttons/Button.ts
@@ -62,6 +62,7 @@
   jslogContext?: string;
   longClickable?: boolean;
   inverseColorTheme?: boolean;
+  accessibleExpanded?: boolean;
 }

 interface CommonButtonData {
@@ -87,6 +88,10 @@
    * Sets aria-label on the internal <button> element.
    */
   accessibleLabel?: string;
+  /**
+   * Sets aria-expanded on the internal <button> element.
+   */
+  accessibleExpanded?: boolean;
 }

 export type ButtonData = CommonButtonData&(|{
@@ -166,6 +171,7 @@
       this.accessibleLabel = data.accessibleLabel;
     }

+    this.#props.accessibleExpanded = data.accessibleExpanded;
     this.#props.jslogContext = data.jslogContext;
     this.#props.longClickable = data.longClickable;
     this.#props.inverseColorTheme = data.inverseColorTheme;
@@ -211,6 +217,15 @@
     return this.getAttribute('accessibleLabel') || undefined;
   }

+  set accessibleExpanded(expanded: boolean|undefined) {
+    this.#props.accessibleExpanded = expanded;
+    this.#render();
+  }
+
+  get accessibleExpanded(): boolean|undefined {
+    return this.#props.accessibleExpanded;
+  }
+
   set reducedFocusRing(reducedFocusRing: boolean) {
     this.#props.reducedFocusRing = reducedFocusRing;
     this.#render();
@@ -385,6 +400,7 @@
                 ?disabled=${this.#props.disabled}
                 class=${classMap(classes)}
                 aria-pressed=${ifDefined(this.#props.toggled)}
+                aria-expanded=${ifDefined(this.#props.accessibleExpanded)}
                 aria-label=${ifDefined(this.accessibleLabel || this.title || undefined)}
                 jslog=${ifDefined(jslog)}>
           ${hasIcon ? html`

테스트 방법

SecurityPanel.test.ts에 다음 세 단위 테스트를 추가했습니다.

  • SAN 목록이 비어 있으면 (n/a)를 표시하는지 확인
  • SAN 항목이 3개이면 모두 표시하고 펼치기·접기 버튼은 없는지 확인
  • SAN 항목이 4개이면 처음에는 2개만 표시하고, 버튼을 누를 때 표시 항목, 버튼 문구, accessibleExpanded 값이 함께 바뀌는지 확인

Button.test.ts에는 accessibleExpanded의 false·true 값이 내부 버튼의 aria-expanded에 반영되고, 속성을 지정하지 않으면 해당 ARIA 속성을 설정하지 않는지 확인하는 테스트 2개를 추가했습니다.

CL 8096246에서 관련 Chromium 웹 테스트의 기대값을 먼저 임시 조정했습니다. 이 CL은 2026년 7월 22일 DevTools main에 병합되었으며, 변경 반영 후 CL 8140384에서 Chromium 쪽 기대 결과를 갱신했습니다.

배운 점

  • 펼치기·접기 UI를 전환할 때는 표시 항목뿐 아니라 버튼 문구와 접근성 상태도 함께 갱신하고 검증해야 합니다.
  • 리뷰에서 이 변경을 점진적인 전환으로 진행하되, 이후 파일 전체를 model-view-presenter 구조로 옮기는 방향을 확인했습니다.

참고 자료