UNPKG

chrome-devtools-frontend

Version:
440 lines (400 loc) • 16.8 kB
// Copyright 2020 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import * as Common from '../../core/common/common.js'; import * as SDK from '../../core/sdk/sdk.js'; import * as Protocol from '../../generated/protocol.js'; import {AttributionReportingIssue} from './AttributionReportingIssue.js'; import {BounceTrackingIssue} from './BounceTrackingIssue.js'; import {ClientHintIssue} from './ClientHintIssue.js'; import {ContentSecurityPolicyIssue} from './ContentSecurityPolicyIssue.js'; import {CorsIssue} from './CorsIssue.js'; import {CrossOriginEmbedderPolicyIssue, isCrossOriginEmbedderPolicyIssue} from './CrossOriginEmbedderPolicyIssue.js'; import {DeprecationIssue} from './DeprecationIssue.js'; import {FederatedAuthRequestIssue} from './FederatedAuthRequestIssue.js'; import {GenericIssue} from './GenericIssue.js'; import {HeavyAdIssue} from './HeavyAdIssue.js'; import {type Issue, type IssueKind} from './Issue.js'; import {Events} from './IssuesManagerEvents.js'; import {LowTextContrastIssue} from './LowTextContrastIssue.js'; import {MixedContentIssue} from './MixedContentIssue.js'; import {NavigatorUserAgentIssue} from './NavigatorUserAgentIssue.js'; import {QuirksModeIssue} from './QuirksModeIssue.js'; import {CookieIssue} from './CookieIssue.js'; import {SharedArrayBufferIssue} from './SharedArrayBufferIssue.js'; import {SourceFrameIssuesManager} from './SourceFrameIssuesManager.js'; import {StylesheetLoadingIssue} from './StylesheetLoadingIssue.js'; export {Events} from './IssuesManagerEvents.js'; let issuesManagerInstance: IssuesManager|null = null; function createIssuesForBlockedByResponseIssue( issuesModel: SDK.IssuesModel.IssuesModel, inspectorIssue: Protocol.Audits.InspectorIssue): CrossOriginEmbedderPolicyIssue[] { const blockedByResponseIssueDetails = inspectorIssue.details.blockedByResponseIssueDetails; if (!blockedByResponseIssueDetails) { console.warn('BlockedByResponse issue without details received.'); return []; } if (isCrossOriginEmbedderPolicyIssue(blockedByResponseIssueDetails.reason)) { return [new CrossOriginEmbedderPolicyIssue(blockedByResponseIssueDetails, issuesModel)]; } return []; } const issueCodeHandlers = new Map< Protocol.Audits.InspectorIssueCode, (model: SDK.IssuesModel.IssuesModel, inspectorIssue: Protocol.Audits.InspectorIssue) => Issue[]>([ [ Protocol.Audits.InspectorIssueCode.CookieIssue, CookieIssue.fromInspectorIssue, ], [ Protocol.Audits.InspectorIssueCode.MixedContentIssue, MixedContentIssue.fromInspectorIssue, ], [ Protocol.Audits.InspectorIssueCode.HeavyAdIssue, HeavyAdIssue.fromInspectorIssue, ], [ Protocol.Audits.InspectorIssueCode.ContentSecurityPolicyIssue, ContentSecurityPolicyIssue.fromInspectorIssue, ], [Protocol.Audits.InspectorIssueCode.BlockedByResponseIssue, createIssuesForBlockedByResponseIssue], [ Protocol.Audits.InspectorIssueCode.SharedArrayBufferIssue, SharedArrayBufferIssue.fromInspectorIssue, ], [ Protocol.Audits.InspectorIssueCode.LowTextContrastIssue, LowTextContrastIssue.fromInspectorIssue, ], [ Protocol.Audits.InspectorIssueCode.CorsIssue, CorsIssue.fromInspectorIssue, ], [ Protocol.Audits.InspectorIssueCode.QuirksModeIssue, QuirksModeIssue.fromInspectorIssue, ], [ Protocol.Audits.InspectorIssueCode.NavigatorUserAgentIssue, NavigatorUserAgentIssue.fromInspectorIssue, ], [ Protocol.Audits.InspectorIssueCode.AttributionReportingIssue, AttributionReportingIssue.fromInspectorIssue, ], [ Protocol.Audits.InspectorIssueCode.GenericIssue, GenericIssue.fromInspectorIssue, ], [ Protocol.Audits.InspectorIssueCode.DeprecationIssue, DeprecationIssue.fromInspectorIssue, ], [ Protocol.Audits.InspectorIssueCode.ClientHintIssue, ClientHintIssue.fromInspectorIssue, ], [ Protocol.Audits.InspectorIssueCode.FederatedAuthRequestIssue, FederatedAuthRequestIssue.fromInspectorIssue, ], [ Protocol.Audits.InspectorIssueCode.BounceTrackingIssue, BounceTrackingIssue.fromInspectorIssue, ], [ Protocol.Audits.InspectorIssueCode.StylesheetLoadingIssue, StylesheetLoadingIssue.fromInspectorIssue, ], ]); /** * Each issue reported by the backend can result in multiple `Issue` instances. * Handlers are simple functions hard-coded into a map. */ function createIssuesFromProtocolIssue( issuesModel: SDK.IssuesModel.IssuesModel, inspectorIssue: Protocol.Audits.InspectorIssue): Issue[] { const handler = issueCodeHandlers.get(inspectorIssue.code); if (handler) { return handler(issuesModel, inspectorIssue); } console.warn(`No handler registered for issue code ${inspectorIssue.code}`); return []; } export interface IssuesManagerCreationOptions { forceNew: boolean; /** Throw an error if this is not the first instance created */ ensureFirst: boolean; showThirdPartyIssuesSetting?: Common.Settings.Setting<boolean>; hideIssueSetting?: Common.Settings.Setting<HideIssueMenuSetting>; } export type HideIssueMenuSetting = { [x: string]: IssueStatus, }; export const enum IssueStatus { Hidden = 'Hidden', Unhidden = 'Unhidden', } export function defaultHideIssueByCodeSetting(): HideIssueMenuSetting { const setting: HideIssueMenuSetting = {}; return setting; } export function getHideIssueByCodeSetting(): Common.Settings.Setting<HideIssueMenuSetting> { return Common.Settings.Settings.instance().createSetting( 'HideIssueByCodeSetting-Experiment-2021', defaultHideIssueByCodeSetting()); } /** * The `IssuesManager` is the central storage for issues. It collects issues from all the * `IssuesModel` instances in the page, and deduplicates them wrt their primary key. * It also takes care of clearing the issues when it sees a main-frame navigated event. * Any client can subscribe to the events provided, and/or query the issues via the public * interface. * * Additionally, the `IssuesManager` can filter Issues. All Issues are stored, but only * Issues that are accepted by the filter cause events to be fired or are returned by * `IssuesManager#issues()`. */ export class IssuesManager extends Common.ObjectWrapper.ObjectWrapper<EventTypes> implements SDK.TargetManager.SDKModelObserver<SDK.IssuesModel.IssuesModel> { #eventListeners = new WeakMap<SDK.IssuesModel.IssuesModel, Common.EventTarget.EventDescriptor>(); #allIssues = new Map<string, Issue>(); #filteredIssues = new Map<string, Issue>(); #issueCounts = new Map<IssueKind, number>(); #hiddenIssueCount = new Map<IssueKind, number>(); #hasSeenPrimaryPageChanged = false; #issuesById: Map<string, Issue> = new Map(); #issuesByOutermostTarget: WeakMap<SDK.Target.Target, Set<Issue>> = new Map(); constructor( private readonly showThirdPartyIssuesSetting?: Common.Settings.Setting<boolean>, private readonly hideIssueSetting?: Common.Settings.Setting<HideIssueMenuSetting>) { super(); new SourceFrameIssuesManager(this); SDK.TargetManager.TargetManager.instance().observeModels(SDK.IssuesModel.IssuesModel, this); SDK.TargetManager.TargetManager.instance().addModelListener( SDK.ResourceTreeModel.ResourceTreeModel, SDK.ResourceTreeModel.Events.PrimaryPageChanged, this.#onPrimaryPageChanged, this); SDK.FrameManager.FrameManager.instance().addEventListener( SDK.FrameManager.Events.FrameAddedToTarget, this.#onFrameAddedToTarget, this); // issueFilter uses the 'showThirdPartyIssues' setting. Clients of IssuesManager need // a full update when the setting changes to get an up-to-date issues list. this.showThirdPartyIssuesSetting?.addChangeListener(() => this.#updateFilteredIssues()); this.hideIssueSetting?.addChangeListener(() => this.#updateFilteredIssues()); SDK.TargetManager.TargetManager.instance().observeTargets( { targetAdded: (target: SDK.Target.Target) => { if (target.outermostTarget() === target) { this.#updateFilteredIssues(); } }, targetRemoved: (_: SDK.Target.Target) => {}, }, {scoped: true}); } static instance(opts: IssuesManagerCreationOptions = { forceNew: false, ensureFirst: false, }): IssuesManager { if (issuesManagerInstance && opts.ensureFirst) { throw new Error( 'IssuesManager was already created. Either set "ensureFirst" to false or make sure that this invocation is really the first one.'); } if (!issuesManagerInstance || opts.forceNew) { issuesManagerInstance = new IssuesManager(opts.showThirdPartyIssuesSetting, opts.hideIssueSetting); } return issuesManagerInstance; } static removeInstance(): void { issuesManagerInstance = null; } /** * Once we have seen at least one `PrimaryPageChanged` event, we can be reasonably sure * that we also collected issues that were reported during the navigation to the current * page. If we haven't seen a main frame navigated, we might have missed issues that arose * during navigation. */ reloadForAccurateInformationRequired(): boolean { return !this.#hasSeenPrimaryPageChanged; } #onPrimaryPageChanged(event: Common.EventTarget.EventTargetEvent<{frame: SDK.ResourceTreeModel.ResourceTreeFrame}>): void { const {frame} = event.data; const keptIssues = new Map<string, Issue>(); for (const [key, issue] of this.#allIssues.entries()) { if (issue.isAssociatedWithRequestId(frame.loaderId)) { keptIssues.set(key, issue); } // Keep BounceTrackingIssues alive for non-user-initiated navigations. if (issue.code() === Protocol.Audits.InspectorIssueCode.BounceTrackingIssue) { const networkManager = frame.resourceTreeModel().target().model(SDK.NetworkManager.NetworkManager); if (networkManager?.requestForLoaderId(frame.loaderId as Protocol.Network.LoaderId)?.hasUserGesture() === false) { keptIssues.set(key, issue); } } } this.#allIssues = keptIssues; this.#hasSeenPrimaryPageChanged = true; this.#updateFilteredIssues(); } #onFrameAddedToTarget(event: Common.EventTarget.EventTargetEvent<{frame: SDK.ResourceTreeModel.ResourceTreeFrame}>): void { const {frame} = event.data; // Determining third-party status usually requires the registered domain of the outermost frame. // When DevTools is opened after navigation has completed, issues may be received // before the outermost frame is available. Thus, we trigger a recalcuation of third-party-ness // when we attach to the outermost frame. if (frame.isOutermostFrame() && SDK.TargetManager.TargetManager.instance().isInScope(frame.resourceTreeModel())) { this.#updateFilteredIssues(); } } modelAdded(issuesModel: SDK.IssuesModel.IssuesModel): void { const listener = issuesModel.addEventListener(SDK.IssuesModel.Events.IssueAdded, this.#onIssueAddedEvent, this); this.#eventListeners.set(issuesModel, listener); } modelRemoved(issuesModel: SDK.IssuesModel.IssuesModel): void { const listener = this.#eventListeners.get(issuesModel); if (listener) { Common.EventTarget.removeEventListeners([listener]); } } #onIssueAddedEvent(event: Common.EventTarget.EventTargetEvent<SDK.IssuesModel.IssueAddedEvent>): void { const {issuesModel, inspectorIssue} = event.data; const issues = createIssuesFromProtocolIssue(issuesModel, inspectorIssue); for (const issue of issues) { this.addIssue(issuesModel, issue); } } addIssue(issuesModel: SDK.IssuesModel.IssuesModel, issue: Issue): void { // Ignore issues without proper description; they are invisible to the user and only cause confusion. if (!issue.getDescription()) { return; } const primaryKey = issue.primaryKey(); if (this.#allIssues.has(primaryKey)) { return; } this.#allIssues.set(primaryKey, issue); const outermostTarget = issuesModel.target().outermostTarget(); if (outermostTarget) { let issuesForTarget = this.#issuesByOutermostTarget.get(outermostTarget); if (!issuesForTarget) { issuesForTarget = new Set(); this.#issuesByOutermostTarget.set(outermostTarget, issuesForTarget); } issuesForTarget.add(issue); } if (this.#issueFilter(issue)) { this.#filteredIssues.set(primaryKey, issue); this.#issueCounts.set(issue.getKind(), 1 + (this.#issueCounts.get(issue.getKind()) || 0)); const issueId = issue.getIssueId(); if (issueId) { this.#issuesById.set(issueId, issue); } const values = this.hideIssueSetting?.get(); this.#updateIssueHiddenStatus(issue, values); if (issue.isHidden()) { this.#hiddenIssueCount.set(issue.getKind(), 1 + (this.#hiddenIssueCount.get(issue.getKind()) || 0)); } this.dispatchEventToListeners(Events.IssueAdded, {issuesModel, issue}); } // Always fire the "count" event even if the issue was filtered out. // The result of `hasOnlyThirdPartyIssues` could still change. this.dispatchEventToListeners(Events.IssuesCountUpdated); } issues(): Iterable<Issue> { return this.#filteredIssues.values(); } numberOfIssues(kind?: IssueKind): number { if (kind) { return (this.#issueCounts.get(kind) ?? 0) - this.numberOfHiddenIssues(kind); } return this.#filteredIssues.size - this.numberOfHiddenIssues(); } numberOfHiddenIssues(kind?: IssueKind): number { if (kind) { return this.#hiddenIssueCount.get(kind) ?? 0; } let count = 0; for (const num of this.#hiddenIssueCount.values()) { count += num; } return count; } numberOfAllStoredIssues(): number { return this.#allIssues.size; } #issueFilter(issue: Issue): boolean { const scopeTarget = SDK.TargetManager.TargetManager.instance().scopeTarget(); if (!scopeTarget) { return false; } if (!this.#issuesByOutermostTarget.get(scopeTarget)?.has(issue)) { return false; } return this.showThirdPartyIssuesSetting?.get() || !issue.isCausedByThirdParty(); } #updateIssueHiddenStatus(issue: Issue, values: HideIssueMenuSetting|undefined): void { const code = issue.code(); // All issues are hidden via their code. // For hiding we check whether the issue code is present and has a value of IssueStatus.Hidden // assosciated with it. If all these conditions are met the issue is hidden. // IssueStatus is set in hidden issues menu. // In case a user wants to hide a specific issue, the issue code is added to "code" section // of our setting and its value is set to IssueStatus.Hidden. Then issue then gets hidden. if (values && values[code]) { if (values[code] === IssueStatus.Hidden) { issue.setHidden(true); return; } issue.setHidden(false); return; } } #updateFilteredIssues(): void { this.#filteredIssues.clear(); this.#issueCounts.clear(); this.#issuesById.clear(); this.#hiddenIssueCount.clear(); const values = this.hideIssueSetting?.get(); for (const [key, issue] of this.#allIssues) { if (this.#issueFilter(issue)) { this.#updateIssueHiddenStatus(issue, values); this.#filteredIssues.set(key, issue); this.#issueCounts.set(issue.getKind(), 1 + (this.#issueCounts.get(issue.getKind()) ?? 0)); if (issue.isHidden()) { this.#hiddenIssueCount.set(issue.getKind(), 1 + (this.#hiddenIssueCount.get(issue.getKind()) || 0)); } const issueId = issue.getIssueId(); if (issueId) { this.#issuesById.set(issueId, issue); } } } this.dispatchEventToListeners(Events.FullUpdateRequired); this.dispatchEventToListeners(Events.IssuesCountUpdated); } unhideAllIssues(): void { for (const issue of this.#allIssues.values()) { issue.setHidden(false); } this.hideIssueSetting?.set(defaultHideIssueByCodeSetting()); } getIssueById(id: string): Issue|undefined { return this.#issuesById.get(id); } } export interface IssueAddedEvent { issuesModel: SDK.IssuesModel.IssuesModel; issue: Issue; } export type EventTypes = { [Events.IssuesCountUpdated]: void, [Events.FullUpdateRequired]: void, [Events.IssueAdded]: IssueAddedEvent, }; // @ts-ignore globalThis.addIssueForTest = (issue: Protocol.Audits.InspectorIssue): void => { const mainTarget = SDK.TargetManager.TargetManager.instance().primaryPageTarget(); const issuesModel = mainTarget?.model(SDK.IssuesModel.IssuesModel); issuesModel?.issueAdded({issue}); };