| // Copyright (C) 2026 The Android Open Source Project | |
| // | |
| // Licensed under the Apache License, Version 2.0 (the "License"); | |
| // you may not use this file except in compliance with the License. | |
| // You may obtain a copy of the License at | |
| // | |
| // http://www.apache.org/licenses/LICENSE-2.0 | |
| // | |
| // Unless required by applicable law or agreed to in writing, software | |
| // distributed under the License is distributed on an "AS IS" BASIS, | |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| // See the License for the specific language governing permissions and | |
| // limitations under the License. | |
| // A recording stand-in for the browser APIs file-search.js uses. | |
| // | |
| // What this is | |
| // ------------ | |
| // file-search.js is an IIFE that exports nothing and starts by looking for | |
| // #file-search in the document. The only way to reach it is to give it a | |
| // document. This builds the smallest one that lets it run, and records what it | |
| // does to it. | |
| // | |
| // What it models faithfully | |
| // ------------------------- | |
| // Bookkeeping the finder performs itself: attributes set and removed, classes | |
| // toggled, children appended, listeners registered and called, timers queued, | |
| // storage read and written. Assertions about those are assertions about our | |
| // code, and a recording cannot drift from what was recorded. | |
| // | |
| // What it does NOT model, and must not be asserted on | |
| // --------------------------------------------------- | |
| // * Event bubbling. `fire()` invokes listeners on the element they were | |
| // registered on; a test names the target explicitly. The finder's own | |
| // handlers use `ev.target.closest(...)`, which is honoured, but a test | |
| // must not expect a click on a child to reach a parent's listener. | |
| // * Focus semantics beyond "focus() was called, and activeElement moved". | |
| // * Layout. scrollIntoView is counted, nothing more. | |
| // * Content-Security-Policy, speculation rules, prefetch caching and | |
| // navigation. Those are observed as "the finder asked", never as "the | |
| // browser complied". The headless-browser checks cover the rest. | |
| // | |
| // Selector support is deliberately partial: descendant chains of class | |
| // selectors with an optional attribute-presence test, which is everything | |
| // file-search.js asks for and nothing more. An unsupported selector throws | |
| // rather than quietly matching nothing. | |
| 'use strict'; | |
| const fs = require('node:fs'); | |
| const vm = require('node:vm'); | |
| const SRC = 'resources/com/google/gitiles/static/file-search.js'; | |
| // ------------------------------------------------------------------ nodes | |
| class TextNode { | |
| constructor(text) { | |
| this.nodeType = 3; | |
| this.textContent = text; | |
| this.parentNode = null; | |
| } | |
| get text() { | |
| return this.textContent; | |
| } | |
| } | |
| class ClassList { | |
| constructor(el) { | |
| this.el = el; | |
| } | |
| _set() { | |
| return new Set(this.el.className ? this.el.className.split(/\s+/).filter(Boolean) : []); | |
| } | |
| _write(s) { | |
| this.el.className = Array.from(s).join(' '); | |
| } | |
| add(n) { | |
| const s = this._set(); | |
| s.add(n); | |
| this._write(s); | |
| } | |
| remove(n) { | |
| const s = this._set(); | |
| s.delete(n); | |
| this._write(s); | |
| } | |
| contains(n) { | |
| return this._set().has(n); | |
| } | |
| toggle(n, force) { | |
| const on = force === undefined ? !this.contains(n) : !!force; | |
| if (on) this.add(n); | |
| else this.remove(n); | |
| return on; | |
| } | |
| } | |
| class Element { | |
| constructor(tagName, doc) { | |
| this.tagName = String(tagName).toUpperCase(); | |
| this.ownerDocument = doc; | |
| this.nodeType = 1; | |
| this.childNodes = []; | |
| this.parentNode = null; | |
| this.attributes = Object.create(null); | |
| this.className = ''; | |
| this.classList = new ClassList(this); | |
| this.hidden = false; | |
| this.listeners = Object.create(null); | |
| this.scrollCount = 0; | |
| this.focusCount = 0; | |
| } | |
| // --- attributes. id/href/tabIndex/type are properties the finder sets | |
| // directly as well as through setAttribute, so keep the two in step. | |
| setAttribute(n, v) { | |
| this.attributes[n] = String(v); | |
| } | |
| getAttribute(n) { | |
| return n in this.attributes ? this.attributes[n] : null; | |
| } | |
| removeAttribute(n) { | |
| delete this.attributes[n]; | |
| } | |
| hasAttribute(n) { | |
| return n in this.attributes; | |
| } | |
| get id() { | |
| return this.getAttribute('id') || ''; | |
| } | |
| set id(v) { | |
| this.setAttribute('id', v); | |
| } | |
| get href() { | |
| return this.getAttribute('href'); | |
| } | |
| set href(v) { | |
| this.setAttribute('href', v); | |
| } | |
| get tabIndex() { | |
| return this.hasAttribute('tabindex') ? Number(this.getAttribute('tabindex')) : 0; | |
| } | |
| set tabIndex(v) { | |
| this.setAttribute('tabindex', v); | |
| } | |
| get type() { | |
| return this.getAttribute('type') || ''; | |
| } | |
| set type(v) { | |
| this.setAttribute('type', v); | |
| } | |
| get nonce() { | |
| return this.getAttribute('nonce') || ''; | |
| } | |
| set nonce(v) { | |
| this.setAttribute('nonce', v); | |
| } | |
| // --- children | |
| appendChild(child) { | |
| if (child.parentNode) child.parentNode.removeChild(child); | |
| child.parentNode = this; | |
| this.childNodes.push(child); | |
| return child; | |
| } | |
| removeChild(child) { | |
| const i = this.childNodes.indexOf(child); | |
| if (i >= 0) this.childNodes.splice(i, 1); | |
| child.parentNode = null; | |
| return child; | |
| } | |
| remove() { | |
| if (this.parentNode) this.parentNode.removeChild(this); | |
| } | |
| get firstChild() { | |
| return this.childNodes.length ? this.childNodes[0] : null; | |
| } | |
| get children() { | |
| return this.childNodes.filter((n) => n.nodeType === 1); | |
| } | |
| get textContent() { | |
| return this.childNodes.map((n) => n.textContent).join(''); | |
| } | |
| set textContent(v) { | |
| this.childNodes = []; | |
| if (v !== '') this.appendChild(new TextNode(String(v))); | |
| } | |
| // --- queries | |
| querySelector(sel) { | |
| return descendants(this).find((e) => matches(e, sel)) || null; | |
| } | |
| querySelectorAll(sel) { | |
| return descendants(this).filter((e) => matches(e, sel)); | |
| } | |
| closest(sel) { | |
| for (let e = this; e; e = e.parentNode) { | |
| if (e.nodeType === 1 && matches(e, sel)) return e; | |
| } | |
| return null; | |
| } | |
| // --- events and the two side effects worth counting | |
| addEventListener(type, fn) { | |
| (this.listeners[type] || (this.listeners[type] = [])).push(fn); | |
| } | |
| scrollIntoView() { | |
| this.scrollCount++; | |
| } | |
| focus() { | |
| this.focusCount++; | |
| if (this.ownerDocument) this.ownerDocument.activeElement = this; | |
| } | |
| } | |
| function descendants(el) { | |
| const out = []; | |
| const walk = (n) => { | |
| for (const c of n.childNodes) { | |
| if (c.nodeType !== 1) continue; | |
| out.push(c); | |
| walk(c); | |
| } | |
| }; | |
| walk(el); | |
| return out; | |
| } | |
| // --------------------------------------------------------------- selectors | |
| /** Parses ".A .B[attr]" into a list of simple selectors, innermost last. */ | |
| function parse(sel) { | |
| return sel | |
| .trim() | |
| .split(/\s+/) | |
| .map((part) => { | |
| const m = /^(\.[A-Za-z0-9_-]+|[A-Za-z]+)(?:\[([A-Za-z-]+)\])?$/.exec(part); | |
| if (!m) throw new Error(`fake_dom: unsupported selector ${JSON.stringify(sel)}`); | |
| return { | |
| cls: m[1][0] === '.' ? m[1].slice(1) : null, | |
| tag: m[1][0] === '.' ? null : m[1].toUpperCase(), | |
| attr: m[2] || null, | |
| }; | |
| }); | |
| } | |
| function simpleMatches(el, s) { | |
| if (s.cls && !el.classList.contains(s.cls)) return false; | |
| if (s.tag && el.tagName !== s.tag) return false; | |
| if (s.attr && !el.hasAttribute(s.attr)) return false; | |
| return true; | |
| } | |
| function matches(el, sel) { | |
| const parts = parse(sel); | |
| if (!simpleMatches(el, parts[parts.length - 1])) return false; | |
| let i = parts.length - 2; | |
| for (let e = el.parentNode; e && i >= 0; e = e.parentNode) { | |
| if (e.nodeType === 1 && simpleMatches(e, parts[i])) i--; | |
| } | |
| return i < 0; | |
| } | |
| // --------------------------------------------------------------- the world | |
| class Document { | |
| constructor() { | |
| this.nodeType = 9; | |
| this.childNodes = []; | |
| this.listeners = Object.create(null); | |
| this.activeElement = null; | |
| this.head = new Element('head', this); | |
| this.body = new Element('body', this); | |
| this.appendChild(this.head); | |
| this.appendChild(this.body); | |
| // A real document always has an active element; it is the body when | |
| // nothing else has focus. Starting at null made "/" look like it was | |
| // being typed into whatever was focused last. | |
| this.activeElement = this.body; | |
| } | |
| createElement(tag) { | |
| return new Element(tag, this); | |
| } | |
| createTextNode(t) { | |
| return new TextNode(t); | |
| } | |
| appendChild(c) { | |
| c.parentNode = this; | |
| this.childNodes.push(c); | |
| return c; | |
| } | |
| getElementById(id) { | |
| return descendants(this).find((e) => e.id === id) || null; | |
| } | |
| querySelector(sel) { | |
| return descendants(this).find((e) => matches(e, sel)) || null; | |
| } | |
| querySelectorAll(sel) { | |
| return descendants(this).filter((e) => matches(e, sel)); | |
| } | |
| addEventListener(type, fn) { | |
| (this.listeners[type] || (this.listeners[type] = [])).push(fn); | |
| } | |
| } | |
| /** Invokes the listeners registered on `el` for `type`. No bubbling. */ | |
| function fire(el, type, event) { | |
| const ev = Object.assign({target: el, preventDefaultCount: 0}, event); | |
| ev.preventDefault = () => { | |
| ev.preventDefaultCount++; | |
| }; | |
| for (const fn of el.listeners[type] || []) fn(ev); | |
| return ev; | |
| } | |
| /** A clock the test drives, standing in for both timers and Date.now. */ | |
| class Clock { | |
| constructor() { | |
| this.now = 1700000000000; | |
| this.timers = new Map(); | |
| this.next = 1; | |
| } | |
| setTimeout(fn, ms) { | |
| const id = this.next++; | |
| this.timers.set(id, {at: this.now + (ms || 0), fn}); | |
| return id; | |
| } | |
| clearTimeout(id) { | |
| this.timers.delete(id); | |
| } | |
| /** Runs everything due within `ms`, in time order. */ | |
| advance(ms) { | |
| const until = this.now + ms; | |
| for (;;) { | |
| let due = null; | |
| for (const [id, t] of this.timers) { | |
| if (t.at <= until && (due === null || t.at < due[1].at)) due = [id, t]; | |
| } | |
| if (!due) break; | |
| this.timers.delete(due[0]); | |
| this.now = due[1].at; | |
| due[1].fn(); | |
| } | |
| this.now = until; | |
| } | |
| } | |
| class FakeStorage { | |
| constructor(opts) { | |
| this.map = new Map(); | |
| this.throwOnGet = (opts && opts.throwOnGet) || false; | |
| this.throwOnSet = (opts && opts.throwOnSet) || false; | |
| } | |
| getItem(k) { | |
| if (this.throwOnGet) throw new Error('storage disabled'); | |
| return this.map.has(k) ? this.map.get(k) : null; | |
| } | |
| setItem(k, v) { | |
| if (this.throwOnSet) throw new Error('storage full'); | |
| this.map.set(k, String(v)); | |
| } | |
| } | |
| // ------------------------------------------------------------------ set-up | |
| /** | |
| * Builds the markup Common.soy renders for the palette. | |
| * | |
| * Kept to what file-search.js actually reaches for: the root and its three | |
| * children, plus the trigger. The real template carries a backdrop and | |
| * labelling this does not need. | |
| */ | |
| function buildPalette(doc, {workerUrl, treeUrl, kind}) { | |
| const root = doc.createElement('div'); | |
| root.id = 'file-search'; | |
| root.hidden = true; | |
| root.setAttribute('data-worker-url', workerUrl); | |
| if (treeUrl) root.setAttribute('data-tree-url', treeUrl); | |
| if (kind) root.setAttribute('data-kind', kind); | |
| const backdrop = doc.createElement('div'); | |
| backdrop.className = 'FileSearch-backdrop'; | |
| root.appendChild(backdrop); | |
| const input = doc.createElement('input'); | |
| input.className = 'FileSearch-input'; | |
| input.value = ''; | |
| root.appendChild(input); | |
| const status = doc.createElement('div'); | |
| status.className = 'FileSearch-status'; | |
| root.appendChild(status); | |
| const list = doc.createElement('ul'); | |
| list.className = 'FileSearch-results'; | |
| root.appendChild(list); | |
| doc.body.appendChild(root); | |
| const trigger = doc.createElement('button'); | |
| trigger.className = 'FileSearch-trigger'; | |
| doc.body.appendChild(trigger); | |
| return {root, backdrop, input, status, list, trigger}; | |
| } | |
| /** Builds the RepoList markup the host index renders, including the header row. */ | |
| function buildRepoList(doc, repos) { | |
| const wrap = doc.createElement('div'); | |
| wrap.className = 'RepoList'; | |
| const header = doc.createElement('div'); | |
| header.className = 'RepoList-item RepoList-item--header'; | |
| const headerName = doc.createElement('span'); | |
| headerName.className = 'RepoList-itemName'; | |
| headerName.textContent = 'Repository'; | |
| header.appendChild(headerName); | |
| wrap.appendChild(header); | |
| for (const r of repos) { | |
| const item = doc.createElement('a'); | |
| item.className = 'RepoList-item'; | |
| item.setAttribute('href', r.href); | |
| const name = doc.createElement('span'); | |
| name.className = 'RepoList-itemName'; | |
| name.textContent = r.name; | |
| item.appendChild(name); | |
| if (r.desc) { | |
| const d = doc.createElement('span'); | |
| d.className = 'RepoList-itemDescription'; | |
| d.textContent = r.desc; | |
| item.appendChild(d); | |
| } | |
| wrap.appendChild(item); | |
| } | |
| doc.body.appendChild(wrap); | |
| return wrap; | |
| } | |
| /** | |
| * Loads file-search.js against a fresh fake document. | |
| * | |
| * Options: | |
| * kind 'files' (default) or 'repositories' | |
| * repos [{name, href, desc}] for the host index | |
| * workerFactory (url) => fake worker, or a function that throws to model a | |
| * constructor refused by policy | |
| * noWorker omit the Worker global entirely | |
| * storage {throwOnGet, throwOnSet} | |
| * speculation whether HTMLScriptElement.supports('speculationrules') is true | |
| */ | |
| function load(options = {}) { | |
| const doc = new Document(); | |
| const clock = new Clock(); | |
| const repoMode = options.kind === 'repositories'; | |
| const el = buildPalette(doc, { | |
| workerUrl: '/+static/file-search-worker.js', | |
| treeUrl: repoMode ? null : '/repo/+/HEAD/?format=JSON&paths_only=1', | |
| kind: repoMode ? 'repositories' : null, | |
| }); | |
| if (repoMode) buildRepoList(doc, options.repos || []); | |
| if (options.nonce) { | |
| const s = doc.createElement('script'); | |
| s.setAttribute('nonce', options.nonce); | |
| doc.body.appendChild(s); | |
| doc.currentScript = s; | |
| } | |
| const workers = []; | |
| const navigations = []; | |
| const opened = []; | |
| const fetches = []; | |
| const storage = new FakeStorage(options.storage); | |
| // A second page load sees whatever the first one left behind. | |
| if (options.storageMap) storage.map = options.storageMap; | |
| function defaultWorkerFactory() { | |
| const w = { | |
| sent: [], | |
| terminated: 0, | |
| onmessage: null, | |
| onerror: null, | |
| postMessage(m) { | |
| // A real postMessage structured-clones across the thread boundary, so | |
| // the worker never receives the page's own objects. Modelling that | |
| // also keeps recorded messages in this realm, where deepStrictEqual's | |
| // prototype check can pass. | |
| this.sent.push(JSON.parse(JSON.stringify(m))); | |
| }, | |
| terminate() { | |
| this.terminated++; | |
| }, | |
| /** Delivers a message as the real worker would. */ | |
| reply(msg) { | |
| if (this.onmessage) this.onmessage({data: msg}); | |
| }, | |
| fail() { | |
| if (this.onerror) this.onerror({}); | |
| }, | |
| }; | |
| return w; | |
| } | |
| const factory = options.workerFactory || defaultWorkerFactory; | |
| const location = {pathname: options.pathname || '/', href: '/'}; | |
| const windowObj = { | |
| localStorage: storage, | |
| listeners: Object.create(null), | |
| addEventListener(type, fn) { | |
| (this.listeners[type] || (this.listeners[type] = [])).push(fn); | |
| }, | |
| open(href, target) { | |
| opened.push({href, target}); | |
| }, | |
| get location() { | |
| return location; | |
| }, | |
| }; | |
| if (options.speculation) { | |
| windowObj.HTMLScriptElement = {supports: (s) => s === 'speculationrules'}; | |
| } | |
| // window.location.href = ... must be observable as a navigation. | |
| Object.defineProperty(location, 'href', { | |
| get: () => location._href || '/', | |
| set: (v) => { | |
| location._href = v; | |
| navigations.push(v); | |
| }, | |
| }); | |
| const sandbox = { | |
| document: doc, | |
| window: windowObj, | |
| location, | |
| console, | |
| JSON, | |
| Math, | |
| Object, | |
| Array, | |
| String, | |
| Number, | |
| isFinite, | |
| encodeURIComponent, | |
| Date: {now: () => clock.now}, | |
| setTimeout: (fn, ms) => clock.setTimeout(fn, ms), | |
| clearTimeout: (id) => clock.clearTimeout(id), | |
| fetch: (url, init) => { | |
| fetches.push({url, init}); | |
| return Promise.resolve({text: () => Promise.resolve('')}); | |
| }, | |
| }; | |
| if (!options.noWorker) { | |
| sandbox.Worker = function (url) { | |
| const w = factory(url); | |
| workers.push(w); | |
| return w; | |
| }; | |
| } | |
| sandbox.HTMLScriptElement = windowObj.HTMLScriptElement; | |
| vm.runInNewContext(fs.readFileSync(SRC, 'utf8'), sandbox, {filename: SRC}); | |
| return { | |
| doc, | |
| clock, | |
| el, | |
| storage, | |
| workers, | |
| navigations, | |
| opened, | |
| fetches, | |
| sandbox, | |
| /** The worker most recently constructed. */ | |
| get worker() { | |
| return workers[workers.length - 1]; | |
| }, | |
| /** Presses a key in the input. */ | |
| key(k, extra) { | |
| return fire(el.input, 'keydown', Object.assign({key: k}, extra)); | |
| }, | |
| /** Presses a key on the document, as a reader not in the palette would. */ | |
| globalKey(k, extra) { | |
| return fire(doc, 'keydown', Object.assign({key: k, target: doc.body}, extra)); | |
| }, | |
| /** Types into the input and lets the finder react. */ | |
| type(text) { | |
| el.input.value = text; | |
| fire(el.input, 'input', {}); | |
| }, | |
| /** The rows currently painted, in display order. */ | |
| visibleRows() { | |
| return el.list.children.filter((li) => !li.hidden); | |
| }, | |
| /** The id named by aria-activedescendant, or null. */ | |
| activeDescendant() { | |
| return el.input.getAttribute('aria-activedescendant'); | |
| }, | |
| status() { | |
| return el.status.textContent; | |
| }, | |
| }; | |
| } | |
| module.exports = {load, fire, Element, Document, Clock}; |