• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright 2024 The Chromium Authors
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import {assert} from 'chrome://resources/js/assert.js';
6import {CustomElement} from 'chrome://resources/js/custom_element.js';
7import {getTrustedHTML} from 'chrome://resources/js/static_types.js';
8
9import type {FieldTrialState, Group, HashNamed, HashNameMap, MetricsInternalsBrowserProxy, Trial} from './browser_proxy.js';
10import {MetricsInternalsBrowserProxyImpl} from './browser_proxy.js';
11import {getTemplate} from './field_trials.html.js';
12
13// Stores and persists study and group names along with their hash.
14class NameUnhasher {
15  private hashNames: Map<string, string> =
16      new Map(JSON.parse(localStorage.getItem('names') || '[]'));
17  // We remember up to this.maxStoredNames names in localStorage.
18  private readonly maxStoredNames = 500;
19
20  add(names: HashNameMap): boolean {
21    let changed = false;
22    for (const [hash, name] of Object.entries(names)) {
23      if (name && !this.hashNames.has(hash)) {
24        changed = true;
25        this.hashNames.set(hash, name);
26      }
27    }
28    if (changed) {
29      // Note: `Map` retains item order, so this keeps the most recent
30      // `maxStoredNames` entries.
31      localStorage.setItem(
32          'names',
33          JSON.stringify(Array.from(this.hashNames.entries())
34                             .slice(-this.maxStoredNames)));
35    }
36    return changed;
37  }
38
39  displayName(named: HashNamed): string {
40    const name = named.name || this.hashNames.get(named.hash);
41    return name ? `${name} (#${named.hash})` : `#${named.hash}`;
42  }
43}
44
45class SearchFilter {
46  private searchParts: Array<[string, string]> = [];
47
48  constructor(private unhasher: NameUnhasher, private searchText: string) {
49    this.searchText = searchText.toLowerCase();
50    // Allow any of these separators. This means we may need to consider more
51    // than one interpretation. For example: "One.Two-Three" could be a single
52    // name, or one of the trial/group combinations "One/Two-Three",
53    // "One.Two/Three".
54    for (const separator of '/.:-') {
55      const parts = this.searchText.split(separator);
56      if (parts.length === 2) {
57        this.searchParts.push(parts as [string, string]);
58      }
59    }
60  }
61
62  match(named: HashNamed, checkParts: boolean): MatchResult {
63    if (this.searchText === '') {
64      return MatchResult.NONE;
65    }
66    let match = this.matchNameOrHash(this.searchText, named);
67    if (!match && checkParts) {
68      for (const parts of this.searchParts) {
69        match = this.matchNameOrHash(parts['groups' in named ? 0 : 1]!, named);
70        if (match) {
71          break;
72        }
73      }
74    }
75    return match ? MatchResult.MATCH : MatchResult.MISMATCH;
76  }
77
78  private matchNameOrHash(search: string, subject: HashNamed): boolean {
79    return this.unhasher.displayName(subject).toLowerCase().includes(search);
80  }
81}
82
83enum MatchResult {
84  // There is no search query.
85  NONE = '',
86  // Matched the search.
87  MATCH = 'match',
88  // Did not match the search.
89  MISMATCH = 'no-match',
90}
91
92export class TrialRow {
93  root: HTMLDivElement;
94  overridden = false;
95  experimentRows: ExperimentRow[] = [];
96
97  constructor(private app: FieldTrialsAppElement, public trial: Trial) {
98    this.root = document.createElement('div');
99    this.root.classList.add('trial-row');
100    this.root.innerHTML = getTrustedHTML`
101      <div class="trial-header">
102        <button class="expand-button"></button>
103        <span class="trial-name"></span>
104      </div>
105      <div class="trial-groups"></div>`;
106
107    for (const group of trial.groups) {
108      this.overridden = this.overridden || group.forceEnabled;
109      const experimentRow = new ExperimentRow(this.app, trial, group);
110      this.experimentRows.push(experimentRow);
111    }
112
113    this.root.querySelector('.trial-groups')!.replaceChildren(
114        ...this.experimentRows.map(r => r.root));
115    this.root.querySelector('.expand-button')!.addEventListener('click', () => {
116      const dataset = this.root.dataset;
117      dataset['expanded'] = String(dataset['expanded'] !== 'true');
118    });
119  }
120
121  update() {
122    this.root.querySelector('.trial-name')!.replaceChildren(
123        this.app.unhasher.displayName(this.trial));
124    for (const row of this.experimentRows) {
125      row.update();
126    }
127  }
128
129  findExperimentRow(groupHash: string): ExperimentRow|undefined {
130    return this.experimentRows.find(row => row.group.hash === groupHash);
131  }
132
133  setMatchResult(result: MatchResult) {
134    this.root.dataset['searchResult'] = result;
135  }
136
137  filter(searchFilter: SearchFilter): [boolean, number] {
138    let matches = 0;
139    let trialResult: MatchResult = searchFilter.match(this.trial, true);
140    for (const row of this.experimentRows) {
141      const result =
142          searchFilter.match(row.group, trialResult === MatchResult.MATCH);
143      row.setMatchResult(result);
144      if (result === MatchResult.MATCH) {
145        trialResult = MatchResult.MATCH;
146        matches++;
147      }
148    }
149    this.root.dataset['searchResult'] = trialResult;
150    return [trialResult === MatchResult.MATCH, matches];
151  }
152
153  displayName(): string {
154    return this.app.unhasher.displayName(this.trial);
155  }
156
157  sortKey(): string {
158    const name = this.displayName();
159    // Order: Overridden trials, trials with names, trials with hash only.
160    return `${Number(!this.overridden)}${Number(name.startsWith('#'))}${name}`;
161  }
162}
163
164class ExperimentRow {
165  root: HTMLDivElement;
166
167  constructor(
168      private app: FieldTrialsAppElement, public trial: Trial,
169      public group: Group) {
170    this.root = document.createElement('div');
171    this.root.classList.add('experiment-row');
172    this.root.innerHTML = getTrustedHTML`
173      <div class="experiment-name"></div>
174      <div class="override">
175        <label for="override">
176          Override <input type="checkbox" name="override">
177        </label>
178      </div>`;
179    if (group.enabled) {
180      this.root.dataset['enrolled'] = '1';
181    }
182    if (group.forceEnabled) {
183      this.setForceEnabled(true);
184    }
185    this.update();
186    this.root.querySelector('input')!.addEventListener(
187        'click', () => this.app.toggleForceEnable(trial, group));
188  }
189
190  update() {
191    this.root.querySelector('.experiment-name')!.replaceChildren(
192        this.app.unhasher.displayName(this.group));
193  }
194
195  setForceEnabled(forceEnabled: boolean) {
196    this.group.forceEnabled = forceEnabled;
197    const checkbox = this.root.querySelector('input')!;
198    checkbox.checked = forceEnabled;
199    if (forceEnabled) {
200      checkbox.dataset['overridden'] = '1';
201    } else {
202      delete checkbox.dataset['overridden'];
203    }
204  }
205
206  setMatchResult(result: MatchResult) {
207    this.root.dataset['searchResult'] = result;
208  }
209}
210
211interface ElementIdMap {
212  'restart-button': HTMLElement;
213  'needs-restart': HTMLElement;
214  'filter': HTMLInputElement;
215  'filter-status': HTMLElement;
216  'field-trial-list': HTMLElement;
217}
218
219export class FieldTrialsAppElement extends CustomElement {
220  static get is(): string {
221    return 'field-trials-app';
222  }
223
224  static override get template() {
225    return getTemplate();
226  }
227
228  private proxy_: MetricsInternalsBrowserProxy =
229      MetricsInternalsBrowserProxyImpl.getInstance();
230
231  // Whether changes require dom updates. Visible for testing.
232  dirty = true;
233  // The list of available trials.
234  private trials: TrialRow[] = [];
235  unhasher = new NameUnhasher();
236
237  onUpdateForTesting = () => {};
238
239  private el<K extends keyof ElementIdMap>(id: K): ElementIdMap[K] {
240    const result = this.shadowRoot!.getElementById(id) as any;
241    assert(result);
242    return result;
243  }
244
245  constructor() {
246    super();
247    // Initialize only when this element is first visible.
248    new Promise<void>(resolve => {
249      const observer = new IntersectionObserver((entries) => {
250        if (entries.filter(entry => entry.intersectionRatio > 0).length > 0) {
251          resolve();
252        }
253      });
254      observer.observe(this);
255    }).then(() => {
256      this.init_();
257    });
258  }
259
260  private init_() {
261    this.proxy_.fetchTrialState().then(state => this.populateState_(state));
262
263    // We're using a form to get autocomplete functionality, but don't need
264    // submit behavior.
265    this.getRequiredElement('form').addEventListener(
266        'submit', (e) => e.preventDefault());
267
268    this.filterInputElement.value = localStorage.getItem('filter') || '';
269    this.filterInputElement.addEventListener(
270        'input', () => this.filterUpdated_());
271    this.el('restart-button')
272        .addEventListener('click', () => this.proxy_.restart());
273    this.filterUpdated_();
274  }
275
276  forceUpdateForTesting() {
277    this.update_();
278  }
279
280  private setRestartRequired_(): void {
281    this.dataset['needsRestart'] = 'true';
282  }
283
284  private filterUpdated_(): void {
285    this.el('filter-status').replaceChildren();
286    localStorage.setItem('filter', this.filterInputElement.value);
287
288    this.proxy_.lookupTrialOrGroupName(this.filterInputElement.value)
289        .then(names => {
290          if (this.unhasher.add(names)) {
291            this.setDirty_();
292          }
293        });
294    this.setDirty_();
295  }
296
297  private setDirty_() {
298    if (this.dirty) {
299      return;
300    }
301    this.dirty = true;
302    window.setTimeout(() => this.update_(), 500);
303  }
304
305  private update_() {
306    if (!this.dirty) {
307      return;
308    }
309    this.dirty = false;
310    for (const trial of this.trials) {
311      trial.update();
312    }
313    this.filterToInput_();
314    this.onUpdateForTesting();
315  }
316
317  get filterInputElement(): HTMLInputElement {
318    return this.el('filter');
319  }
320
321  private findTrialRow(trial: Trial): TrialRow|undefined {
322    for (const t of this.trials) {
323      if (t.trial.hash === trial.hash) {
324        return t;
325      }
326    }
327    return undefined;
328  }
329
330  toggleForceEnable(trial: Trial, group: Group) {
331    group.forceEnabled = !group.forceEnabled;
332    const trialRow = this.findTrialRow(trial);
333    if (trialRow) {
334      for (const row of trialRow.experimentRows) {
335        row.setForceEnabled(
336            group.forceEnabled && row.group.hash === group.hash);
337      }
338    }
339
340    this.proxy_.setTrialEnrollState(trial.hash, group.hash, group.forceEnabled);
341    this.setRestartRequired_();
342  }
343
344  private populateState_(state: FieldTrialState) {
345    const trialListDiv = this.el('field-trial-list');
346    this.trials = state.trials.map(t => new TrialRow(this, t));
347    this.trials.sort((a, b) => a.sortKey().localeCompare(b.sortKey()));
348    trialListDiv.replaceChildren(...this.trials.map(t => t.root));
349    this.dirty = true;
350    if (state.restartRequired) {
351      this.setRestartRequired_();
352    }
353    this.update_();
354  }
355
356  private filterToInput_(): void {
357    this.filter_(this.filterInputElement.value);
358  }
359
360  private filter_(searchText: string): void {
361    const searchFilter = new SearchFilter(this.unhasher, searchText);
362    let matchGroupCount = 0;
363    let matchTrialCount = 0;
364    let totalExperimentCount = 0;
365    for (const trial of this.trials) {
366      const [matched, matchedGroups] = trial.filter(searchFilter);
367      if (matched) {
368        ++matchTrialCount;
369      }
370      matchGroupCount += matchedGroups;
371      totalExperimentCount += trial.experimentRows.length;
372    }
373    // Expand all if the search term matches fewer than half of all experiment
374    // groups.
375    this.el('field-trial-list').dataset['expandAll'] = String(
376        matchGroupCount > 0 && matchGroupCount < totalExperimentCount / 2);
377    this.el('filter-status')
378        .replaceChildren(
379            ` (matched ${matchTrialCount} trials, ${matchGroupCount} groups)`);
380  }
381}
382
383declare global {
384  interface HTMLElementTagNameMap {
385    'field-trials-app': FieldTrialsAppElement;
386  }
387}
388
389customElements.define(FieldTrialsAppElement.is, FieldTrialsAppElement);
390