chromium/components/metrics/debug/field_trials.ts

// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {assert} from 'chrome://resources/js/assert.js';
import {CustomElement} from 'chrome://resources/js/custom_element.js';
import {getTrustedHTML} from 'chrome://resources/js/static_types.js';

import type {FieldTrialState, Group, HashNamed, HashNameMap, MetricsInternalsBrowserProxy, Trial} from './browser_proxy.js';
import {MetricsInternalsBrowserProxyImpl} from './browser_proxy.js';
import {getTemplate} from './field_trials.html.js';

// Stores and persists study and group names along with their hash.
class NameUnhasher {
  private hashNames: Map<string, string> =
      new Map(JSON.parse(localStorage.getItem('names') || '[]'));
  // We remember up to this.maxStoredNames names in localStorage.
  private readonly maxStoredNames = 500;

  add(names: HashNameMap): boolean {
    let changed = false;
    for (const [hash, name] of Object.entries(names)) {
      if (name && !this.hashNames.has(hash)) {
        changed = true;
        this.hashNames.set(hash, name);
      }
    }
    if (changed) {
      // Note: `Map` retains item order, so this keeps the most recent
      // `maxStoredNames` entries.
      localStorage.setItem(
          'names',
          JSON.stringify(Array.from(this.hashNames.entries())
                             .slice(-this.maxStoredNames)));
    }
    return changed;
  }

  displayName(named: HashNamed): string {
    const name = named.name || this.hashNames.get(named.hash);
    return name ? `${name} (#${named.hash})` : `#${named.hash}`;
  }
}

class SearchFilter {
  private searchParts: Array<[string, string]> = [];

  constructor(private unhasher: NameUnhasher, private searchText: string) {
    this.searchText = searchText.toLowerCase();
    // Allow any of these separators. This means we may need to consider more
    // than one interpretation. For example: "One.Two-Three" could be a single
    // name, or one of the trial/group combinations "One/Two-Three",
    // "One.Two/Three".
    for (const separator of '/.:-') {
      const parts = this.searchText.split(separator);
      if (parts.length === 2) {
        this.searchParts.push(parts as [string, string]);
      }
    }
  }

  match(named: HashNamed, checkParts: boolean): MatchResult {
    if (this.searchText === '') {
      return MatchResult.NONE;
    }
    let match = this.matchNameOrHash(this.searchText, named);
    if (!match && checkParts) {
      for (const parts of this.searchParts) {
        match = this.matchNameOrHash(parts['groups' in named ? 0 : 1]!, named);
        if (match) {
          break;
        }
      }
    }
    return match ? MatchResult.MATCH : MatchResult.MISMATCH;
  }

  private matchNameOrHash(search: string, subject: HashNamed): boolean {
    return this.unhasher.displayName(subject).toLowerCase().includes(search);
  }
}

enum MatchResult {
  // There is no search query.
  NONE = '',
  // Matched the search.
  MATCH = 'match',
  // Did not match the search.
  MISMATCH = 'no-match',
}

export class TrialRow {
  root: HTMLDivElement;
  overridden = false;
  experimentRows: ExperimentRow[] = [];

  constructor(private app: FieldTrialsAppElement, public trial: Trial) {
    this.root = document.createElement('div');
    this.root.classList.add('trial-row');
    this.root.innerHTML = getTrustedHTML`
      <div class="trial-header">
        <button class="expand-button"></button>
        <span class="trial-name"></span>
      </div>
      <div class="trial-groups"></div>`;

    for (const group of trial.groups) {
      this.overridden = this.overridden || group.forceEnabled;
      const experimentRow = new ExperimentRow(this.app, trial, group);
      this.experimentRows.push(experimentRow);
    }

    this.root.querySelector('.trial-groups')!.replaceChildren(
        ...this.experimentRows.map(r => r.root));
    this.root.querySelector('.expand-button')!.addEventListener('click', () => {
      const dataset = this.root.dataset;
      dataset['expanded'] = String(dataset['expanded'] !== 'true');
    });
  }

  update() {
    this.root.querySelector('.trial-name')!.replaceChildren(
        this.app.unhasher.displayName(this.trial));
    for (const row of this.experimentRows) {
      row.update();
    }
  }

  findExperimentRow(groupHash: string): ExperimentRow|undefined {
    return this.experimentRows.find(row => row.group.hash === groupHash);
  }

  setMatchResult(result: MatchResult) {
    this.root.dataset['searchResult'] = result;
  }

  filter(searchFilter: SearchFilter): [boolean, number] {
    let matches = 0;
    let trialResult: MatchResult = searchFilter.match(this.trial, true);
    for (const row of this.experimentRows) {
      const result =
          searchFilter.match(row.group, trialResult === MatchResult.MATCH);
      row.setMatchResult(result);
      if (result === MatchResult.MATCH) {
        trialResult = MatchResult.MATCH;
        matches++;
      }
    }
    this.root.dataset['searchResult'] = trialResult;
    return [trialResult === MatchResult.MATCH, matches];
  }

  displayName(): string {
    return this.app.unhasher.displayName(this.trial);
  }

  sortKey(): string {
    const name = this.displayName();
    // Order: Overridden trials, trials with names, trials with hash only.
    return `${Number(!this.overridden)}${Number(name.startsWith('#'))}${name}`;
  }
}

class ExperimentRow {
  root: HTMLDivElement;

  constructor(
      private app: FieldTrialsAppElement, public trial: Trial,
      public group: Group) {
    this.root = document.createElement('div');
    this.root.classList.add('experiment-row');
    this.root.innerHTML = getTrustedHTML`
      <div class="experiment-name"></div>
      <div class="override">
        <label for="override">
          Override <input type="checkbox" name="override">
        </label>
      </div>`;
    if (group.enabled) {
      this.root.dataset['enrolled'] = '1';
    }
    if (group.forceEnabled) {
      this.setForceEnabled(true);
    }
    this.update();
    this.root.querySelector('input')!.addEventListener(
        'click', () => this.app.toggleForceEnable(trial, group));
  }

  update() {
    this.root.querySelector('.experiment-name')!.replaceChildren(
        this.app.unhasher.displayName(this.group));
  }

  setForceEnabled(forceEnabled: boolean) {
    this.group.forceEnabled = forceEnabled;
    const checkbox = this.root.querySelector('input')!;
    checkbox.checked = forceEnabled;
    if (forceEnabled) {
      checkbox.dataset['overridden'] = '1';
    } else {
      delete checkbox.dataset['overridden'];
    }
  }

  setMatchResult(result: MatchResult) {
    this.root.dataset['searchResult'] = result;
  }
}

interface ElementIdMap {
  'restart-button': HTMLElement;
  'needs-restart': HTMLElement;
  'filter': HTMLInputElement;
  'filter-status': HTMLElement;
  'field-trial-list': HTMLElement;
}

export class FieldTrialsAppElement extends CustomElement {
  static get is(): string {
    return 'field-trials-app';
  }

  static override get template() {
    return getTemplate();
  }

  private proxy_: MetricsInternalsBrowserProxy =
      MetricsInternalsBrowserProxyImpl.getInstance();

  // Whether changes require dom updates. Visible for testing.
  dirty = true;
  // The list of available trials.
  private trials: TrialRow[] = [];
  unhasher = new NameUnhasher();

  onUpdateForTesting = () => {};

  private el<K extends keyof ElementIdMap>(id: K): ElementIdMap[K] {
    const result = this.shadowRoot!.getElementById(id) as any;
    assert(result);
    return result;
  }

  constructor() {
    super();
    // Initialize only when this element is first visible.
    new Promise<void>(resolve => {
      const observer = new IntersectionObserver((entries) => {
        if (entries.filter(entry => entry.intersectionRatio > 0).length > 0) {
          resolve();
        }
      });
      observer.observe(this);
    }).then(() => {
      this.init_();
    });
  }

  private init_() {
    this.proxy_.fetchTrialState().then(state => this.populateState_(state));

    // We're using a form to get autocomplete functionality, but don't need
    // submit behavior.
    this.getRequiredElement('form').addEventListener(
        'submit', (e) => e.preventDefault());

    this.filterInputElement.value = localStorage.getItem('filter') || '';
    this.filterInputElement.addEventListener(
        'input', () => this.filterUpdated_());
    this.el('restart-button')
        .addEventListener('click', () => this.proxy_.restart());
    this.filterUpdated_();
  }

  forceUpdateForTesting() {
    this.update_();
  }

  private setRestartRequired_(): void {
    this.dataset['needsRestart'] = 'true';
  }

  private filterUpdated_(): void {
    this.el('filter-status').replaceChildren();
    localStorage.setItem('filter', this.filterInputElement.value);

    this.proxy_.lookupTrialOrGroupName(this.filterInputElement.value)
        .then(names => {
          if (this.unhasher.add(names)) {
            this.setDirty_();
          }
        });
    this.setDirty_();
  }

  private setDirty_() {
    if (this.dirty) {
      return;
    }
    this.dirty = true;
    window.setTimeout(() => this.update_(), 500);
  }

  private update_() {
    if (!this.dirty) {
      return;
    }
    this.dirty = false;
    for (const trial of this.trials) {
      trial.update();
    }
    this.filterToInput_();
    this.onUpdateForTesting();
  }

  get filterInputElement(): HTMLInputElement {
    return this.el('filter');
  }

  private findTrialRow(trial: Trial): TrialRow|undefined {
    for (const t of this.trials) {
      if (t.trial.hash === trial.hash) {
        return t;
      }
    }
    return undefined;
  }

  toggleForceEnable(trial: Trial, group: Group) {
    group.forceEnabled = !group.forceEnabled;
    const trialRow = this.findTrialRow(trial);
    if (trialRow) {
      for (const row of trialRow.experimentRows) {
        row.setForceEnabled(group.forceEnabled && row.group.hash == group.hash);
      }
    }

    this.proxy_.setTrialEnrollState(trial.hash, group.hash, group.forceEnabled);
    this.setRestartRequired_();
  }

  private populateState_(state: FieldTrialState) {
    const trialListDiv = this.el('field-trial-list');
    this.trials = state.trials.map(t => new TrialRow(this, t));
    this.trials.sort((a, b) => a.sortKey().localeCompare(b.sortKey()));
    trialListDiv.replaceChildren(...this.trials.map(t => t.root));
    this.dirty = true;
    if (state.restartRequired) {
      this.setRestartRequired_();
    }
    this.update_();
  }

  private filterToInput_(): void {
    this.filter_(this.filterInputElement.value);
  }

  private filter_(searchText: string): void {
    const searchFilter = new SearchFilter(this.unhasher, searchText);
    let matchGroupCount = 0;
    let matchTrialCount = 0;
    let totalExperimentCount = 0;
    for (const trial of this.trials) {
      const [matched, matchedGroups] = trial.filter(searchFilter);
      if (matched) {
        ++matchTrialCount;
      }
      matchGroupCount += matchedGroups;
      totalExperimentCount += trial.experimentRows.length;
    }
    // Expand all if the search term matches fewer than half of all experiment
    // groups.
    this.el('field-trial-list').dataset['expandAll'] = String(
        matchGroupCount > 0 && matchGroupCount < totalExperimentCount / 2);
    this.el('filter-status')
        .replaceChildren(
            ` (matched ${matchTrialCount} trials, ${matchGroupCount} groups)`);
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'field-trials-app': FieldTrialsAppElement;
  }
}

customElements.define(FieldTrialsAppElement.is, FieldTrialsAppElement);