기여 목록 / 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 구조로 옮기는 방향을 확인했습니다.