blob: 40100a134aca493e0bd82caa9beeb55edb000f78 [file] [edit]
// 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.
// Tests for the file finder's search worker.
//
// The worker is a script, not a module: it exports nothing and assigns
// `self.onmessage`. That message protocol is the seam these tests use, so the
// code under test needs no modification and is exercised through exactly the
// surface a browser drives it through.
const {test, describe} = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const vm = require('node:vm');
const SRC = 'resources/com/google/gitiles/static/file-search-worker.js';
/** The worker's display cap; see LIMIT in the source. */
const LIMIT = 20;
/**
* Evaluates the worker in a fresh global containing only what a real
* DedicatedWorkerGlobalScope would offer it, and returns a handle for talking
* to it.
*/
function loadWorker(fetchImpl) {
const outbox = [];
const sandbox = {
TextEncoder,
TextDecoder,
fetch: fetchImpl,
self: {
onmessage: null,
// A real postMessage structured-clones across the worker boundary, so
// the main thread never sees the worker's own objects. Modelling that is
// both faithful and necessary: values built inside a vm context carry
// that context's prototypes, and assert.deepStrictEqual compares
// prototypes.
postMessage: (m) => outbox.push(JSON.parse(JSON.stringify(m))),
},
};
vm.runInNewContext(fs.readFileSync(SRC, 'utf8'), sandbox, {filename: SRC});
return {
outbox,
/** Delivers one message and returns the reply, or undefined if silent. */
async send(msg) {
const before = outbox.length;
await sandbox.self.onmessage({data: msg});
return outbox.length > before ? outbox[outbox.length - 1] : undefined;
},
};
}
/** Serves `paths` as the listing endpoint would, guard prefix and all. */
function serving(paths) {
return async () => ({
ok: true,
status: 200,
text: async () => ")]}'\n" + JSON.stringify({paths}),
});
}
/**
* Builds a worker holding an index over `paths`.
*
* The paths must be in byte order, as a recursive git tree listing is: the
* index binary searches them.
*/
async function indexed(paths) {
const w = loadWorker(serving(paths));
const r = await w.send({type: 'load', url: '/+/HEAD/?format=JSON'});
assert.strictEqual(r.type, 'ready', `load failed: ${JSON.stringify(r)}`);
assert.strictEqual(r.total, paths.length);
return w;
}
/**
* Builds a worker from a listing the page already had, in any order.
*
* No fetch is reachable here, so a worker that tried to make one would fail
* rather than quietly succeed.
*/
async function fromNames(names) {
const w = loadWorker(async () => {
throw new Error('a supplied listing must not be fetched');
});
const r = await w.send({type: 'load', names});
assert.strictEqual(r.type, 'ready', `load failed: ${JSON.stringify(r)}`);
assert.strictEqual(r.total, names.length);
return w;
}
/** Types a query one keystroke at a time, the way a reader produces it. */
async function type(w, q) {
let last;
for (let k = 1; k <= q.length; k++) {
last = await w.send({type: 'query', seq: k, q: q.slice(0, k)});
}
return last;
}
const CORPUS = [
'base/base64.h',
'base/logging.h',
'chrome/test/data/dromaeo/tests/dromaeo-string-base64.html',
'net/base/base64.h',
'third_party/blink/web_tests/tables/bug3166-4.html',
];
describe('loading', () => {
test('strips the anti-XSSI prefix from the listing', async () => {
const w = loadWorker(serving(CORPUS));
const r = await w.send({type: 'load', url: '/+/HEAD/?format=JSON'});
assert.deepStrictEqual(r, {type: 'ready', total: CORPUS.length});
});
test('reports a failed fetch instead of throwing', async () => {
const w = loadWorker(async () => ({ok: false, status: 503}));
const r = await w.send({type: 'load', url: '/+/HEAD/?format=JSON'});
assert.strictEqual(r.type, 'error');
assert.match(r.message, /503/);
});
test('ignores a query that arrives before the index', async () => {
const w = loadWorker(serving(CORPUS));
assert.strictEqual(await w.send({type: 'query', seq: 1, q: 'base'}), undefined);
assert.strictEqual(await w.send({type: 'recent', seq: 2, paths: []}), undefined);
});
});
describe('a listing the page already had', () => {
test('is used instead of fetching one', async () => {
const w = await fromNames(CORPUS.slice());
const r = await type(w, 'logging.h');
assert.strictEqual(r.items[0].path, 'base/logging.h');
});
test('is sorted, whatever order it arrives in', async () => {
// The index binary searches its paths, so an unsorted build silently
// fails to find paths that are present.
const w = await fromNames(CORPUS.slice().reverse());
const r = await w.send({type: 'recent', seq: 1, paths: CORPUS.slice()});
assert.deepStrictEqual(r.items.map((i) => i.path), CORPUS);
});
test('is sorted by UTF-8 bytes, not by UTF-16 code units', async () => {
// The two orders disagree above the basic plane. Array#sort would put
// U+10000 first, because its leading surrogate is 0xD800; the UTF-8
// encodings run the other way, EF BF BD before F0 90 80 80. Sorting the
// wrong way makes the byte-wise binary search miss.
const names = ['dir/\u{10000}.txt', 'dir/\uFFFD.txt'];
const w = await fromNames(names.slice());
const r = await w.send({type: 'recent', seq: 1, paths: names.slice()});
assert.strictEqual(r.items.length, 2);
});
});
describe('ranking', () => {
test('a basename carrying the whole query outranks an incidental match', async () => {
// The directory "base" can consume "base" out of the query, which would
// score base/base64.h as a directory-assisted match and sink it below
// files whose directories contributed nothing.
const w = await indexed(CORPUS);
const r = await type(w, 'base64.h');
assert.deepStrictEqual(r.items.slice(0, 2).map((i) => i.path), [
'base/base64.h',
'net/base/base64.h',
]);
});
test('a basename carrying the post-slash query is not robbed by a deeper directory', async () => {
const w = await indexed([
'java/com/google/gitiles/GitilesView.java',
'java/com/google/gitiles/ViewFilter.java',
'net/base/64.h',
'net/base/base64.h',
]);
const r1 = await type(w, 'java/gitilesview');
assert.deepStrictEqual(
r1.items.map((i) => i.path),
[
'java/com/google/gitiles/GitilesView.java',
'java/com/google/gitiles/ViewFilter.java',
],
);
const r2 = await type(w, 'net/base64.h');
assert.deepStrictEqual(
r2.items.map((i) => i.path),
['net/base/base64.h', 'net/base/64.h'],
);
assert.deepStrictEqual(r2.items[0].ranges, [
[0, 4],
[9, 8],
]);
});
test('a shallower path breaks a tie', async () => {
const w = await indexed(CORPUS);
const r = await type(w, 'logging.h');
assert.strictEqual(r.items[0].path, 'base/logging.h');
// Full staticRank precision is preserved in subsequence channels, so a
// depth-1 path beats a depth-2 path even when staticRank >> 4 would tie.
const w2 = await indexed(['a/b/logging_util.h', 'content/logging_util.h']);
const r2 = await type(w2, 'lutil');
assert.deepStrictEqual(
r2.items.map((i) => i.path),
['content/logging_util.h', 'a/b/logging_util.h'],
);
});
test('a basename prefix outranks a non-prefix even when fewer than LIMIT', async () => {
// When channel B has fewer than LIMIT matches, channel C or D fills the
// remaining rows; B's prefix matches must stay ahead of non-prefix matches
// with higher camelCase or separator bonuses.
const w = await indexed([
'dir/abcdef_long_suffix_to_penalize.txt',
'dir/x_ab_cd.txt',
]);
const r = await type(w, 'abcd');
assert.deepStrictEqual(
r.items.map((i) => i.path),
['dir/abcdef_long_suffix_to_penalize.txt', 'dir/x_ab_cd.txt'],
);
});
test('weak basename matches with negative scores still return rows', async () => {
// A single non-boundary match inside a long basename has a negative
// length-penalised score, which must still be offered rather than dropped.
const paths = [];
for (let i = 0; i < LIMIT + 5; i++) {
paths.push(`dir/f${String(i).padStart(2, '0')}x${'a'.repeat(50)}.txt`);
}
const w = await indexed(paths);
const r = await type(w, 'x');
assert.strictEqual(r.items.length, LIMIT);
});
test('echoes the sequence number so the UI can discard stale replies', async () => {
const w = await indexed(CORPUS);
const r = await w.send({type: 'query', seq: 7, q: 'base64'});
assert.strictEqual(r.seq, 7);
assert.strictEqual(r.q, 'base64');
});
});
describe('empty query', () => {
test('offers a capped, exact sample of the tree', async () => {
const paths = [];
for (let i = 0; i < 30; i++) paths.push(`dir/f${String(i).padStart(3, '0')}.txt`);
const w = await indexed(paths);
const r = await w.send({type: 'query', seq: 1, q: ''});
assert.strictEqual(r.total, paths.length);
assert.strictEqual(r.exact, true);
assert.strictEqual(r.items.length, LIMIT);
assert.deepStrictEqual(r.items[0].ranges, []);
});
});
describe('path-prefix matches', () => {
test('are promoted and marked across the whole query', async () => {
const w = await indexed(CORPUS);
const r = await type(w, 'base/base64');
assert.strictEqual(r.items[0].path, 'base/base64.h');
assert.deepStrictEqual(r.items[0].ranges, [[0, 'base/base64'.length]]);
});
test('report an inexact total once they fill the display', async () => {
// The count is a lower bound: other paths may match without starting with
// the query, so the display has to say "+".
const paths = [];
for (let i = 0; i < LIMIT + 5; i++) {
paths.push(`pkg/f${String(i).padStart(3, '0')}.txt`);
}
const w = await indexed(paths);
const r = await type(w, 'pkg/');
assert.strictEqual(r.total, paths.length);
assert.strictEqual(r.exact, false);
assert.strictEqual(r.items.length, LIMIT);
});
});
describe('highlight ranges', () => {
test('are omitted for non-ASCII paths', async () => {
// Ranges are UTF-8 byte offsets and the UI applies them as UTF-16 string
// indices. They agree only for ASCII; elsewhere, no marks beat wrong ones.
const paths = ['dir/plain.txt', 'dir/\u2605\u661f\u2605.txt'];
const w = await indexed(paths);
const ascii = await type(w, 'plain');
assert.deepStrictEqual(ascii.items[0].ranges, [[4, 5]]);
const w2 = await indexed(paths);
const star = await type(w2, '\u2605\u661f\u2605');
assert.strictEqual(star.items[0].path, 'dir/\u2605\u661f\u2605.txt');
assert.deepStrictEqual(star.items[0].ranges, []);
});
test('marks directory characters consumed by the full-path channel', async () => {
const w = await indexed(['base/task/thread.h', 'other/file.h']);
const r = await type(w, 'bt/thread');
assert.strictEqual(r.items[0].path, 'base/task/thread.h');
assert.deepStrictEqual(r.items[0].ranges, [
[0, 1],
[5, 1],
[9, 7],
]);
});
test('preserves a leading U+FEFF in a path name', async () => {
const w = await indexed(['\ufeffbom.txt']);
const r = await type(w, 'bom');
assert.strictEqual(r.items[0].path, '\ufeffbom.txt');
});
});
describe('remembered files', () => {
test('are cut to the number of rows the UI paints', async () => {
// Otherwise a reader could arrow past the last painted row into a
// selection that highlighted nothing and did nothing on Enter.
const paths = [];
for (let i = 0; i < 40; i++) paths.push(`dir/f${String(i).padStart(3, '0')}.txt`);
const w = await indexed(paths);
const r = await w.send({type: 'recent', seq: 1, paths: paths.slice()});
assert.strictEqual(r.items.length, LIMIT);
assert.strictEqual(r.recent, true);
assert.strictEqual(r.q, '');
});
test('drop paths that no longer exist at this revision', async () => {
const w = await indexed(['dir/kept.txt']);
const r = await w.send({
type: 'recent',
seq: 1,
paths: ['dir/kept.txt', 'dir/deleted.txt'],
});
assert.deepStrictEqual(r.items.map((i) => i.path), ['dir/kept.txt']);
});
test('keep the caller\'s order, since the scores live in the UI', async () => {
const paths = ['dir/a.txt', 'dir/b.txt', 'dir/c.txt'];
const w = await indexed(paths);
const r = await w.send({
type: 'recent',
seq: 1,
paths: ['dir/c.txt', 'dir/a.txt', 'dir/b.txt'],
});
assert.deepStrictEqual(r.items.map((i) => i.path), [
'dir/c.txt',
'dir/a.txt',
'dir/b.txt',
]);
});
});