diff --git a/assets/js/autocomplete/__tests__/context.ts b/assets/js/autocomplete/__tests__/context.ts
new file mode 100644
index 00000000..fabfab23
--- /dev/null
+++ b/assets/js/autocomplete/__tests__/context.ts
@@ -0,0 +1,269 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import { fetchMock } from '../../../test/fetch-mock';
+import { listenAutocomplete } from '..';
+import { fireEvent } from '@testing-library/dom';
+import { assertNotNull } from '../../utils/assert';
+import { TextInputElement } from '../input';
+import store from '../../utils/store';
+import { GetTagSuggestionsResponse } from 'autocomplete/client';
+
+/**
+ * A reusable test environment for autocompletion tests. Note that it does no
+ * attempt to provide environment cleanup functionality. Yes, if you use this
+ * in several tests in one file, then tests will conflict with each other.
+ *
+ * The main problem of implementing the cleanup here is that autocomplete code
+ * adds event listeners to the `document` object. Some of them could be moved
+ * to the `
` element, but events such as `'storage'` are only available
+ * on the document object.
+ *
+ * Unfortunately, there isn't a good easy way to reload the DOM completely in
+ * `jsdom`, so it's expected that you define a single test per file so that
+ * `vitest` runs every test in an isolated process, where no cleanup is needed.
+ *
+ * I wish `vitest` actually did that by default, because cleanup logic and test
+ * in-process test isolation is just boilerplate that we could avoid at this
+ * scale at least.
+ */
+export class TestContext {
+ private input: TextInputElement;
+ private popup: HTMLElement;
+ readonly fakeAutocompleteResponse: Response;
+
+ constructor(fakeAutocompleteResponse: Response) {
+ this.fakeAutocompleteResponse = fakeAutocompleteResponse;
+
+ vi.useFakeTimers().setSystemTime(0);
+ fetchMock.enableMocks();
+
+ // Our mock backend implementation.
+ fetchMock.mockResponse(request => {
+ if (request.url.includes('/autocomplete/compiled')) {
+ return this.fakeAutocompleteResponse;
+ }
+
+ const url = new URL(request.url);
+ if (url.searchParams.get('term')?.toLowerCase() !== 'mar') {
+ const suggestions: GetTagSuggestionsResponse = { suggestions: [] };
+ return JSON.stringify(suggestions);
+ }
+
+ const suggestions: GetTagSuggestionsResponse = {
+ suggestions: [
+ {
+ alias: 'marvelous',
+ canonical: 'beautiful',
+ images: 30,
+ },
+ {
+ canonical: 'mare',
+ images: 20,
+ },
+ {
+ canonical: 'market',
+ images: 10,
+ },
+ ],
+ };
+
+ return JSON.stringify(suggestions);
+ });
+
+ store.set('enable_search_ac', true);
+
+ document.body.innerHTML = `
+
+ `;
+
+ listenAutocomplete();
+
+ this.input = assertNotNull(document.querySelector('.test-input'));
+ this.popup = assertNotNull(document.querySelector('.autocomplete'));
+
+ expect(fetch).not.toBeCalled();
+ }
+
+ async submitForm(input?: string) {
+ if (input) {
+ await this.setInput(input);
+ }
+
+ this.input.form!.submit();
+
+ await this.setInput('');
+ }
+
+ async focusInput() {
+ this.input.focus();
+ await vi.runAllTimersAsync();
+ }
+
+ /**
+ * Sets the input to `value`. Allows for a special `<>` syntax. These characters
+ * are removed from the input. Their position is used to set the selection.
+ *
+ * - `<` denotes the `selectionStart`
+ * - `>` denotes the `selectionEnd`.
+ */
+ async setInput(value: string) {
+ if (document.activeElement !== this.input) {
+ await this.focusInput();
+ }
+
+ const valueChars = [...value];
+
+ const selectionStart = valueChars.indexOf('<');
+ if (selectionStart >= 0) {
+ valueChars.splice(selectionStart, 1);
+ }
+
+ const selectionEnd = valueChars.indexOf('>');
+ if (selectionEnd >= 0) {
+ valueChars.splice(selectionEnd, 1);
+ }
+
+ this.input.value = valueChars.join('');
+ if (selectionStart >= 0) {
+ this.input.selectionStart = selectionStart;
+ }
+ if (selectionEnd >= 0) {
+ this.input.selectionEnd = selectionEnd;
+ }
+
+ fireEvent.input(this.input, { target: { value: this.input.value } });
+
+ await vi.runAllTimersAsync();
+ }
+
+ async keyDown(code: string, params?: { ctrlKey?: boolean }) {
+ fireEvent.keyDown(this.input, { code, ...(params ?? {}) });
+ await vi.runAllTimersAsync();
+ }
+
+ expectRequests() {
+ const snapshot = vi.mocked(fetch).mock.calls.map(([input]) => {
+ const request = input as unknown as Request;
+ const meta: Record = {};
+
+ const url = new URL(request.url);
+
+ const methodAndUrl = `${request.method} ${url}`;
+
+ if (request.credentials !== 'same-origin') {
+ meta.credentials = request.credentials;
+ }
+
+ if (request.cache !== 'default') {
+ meta.cache = request.cache;
+ }
+
+ if (Object.getOwnPropertyNames(meta).length === 0) {
+ return methodAndUrl;
+ }
+
+ return {
+ dest: methodAndUrl,
+ meta,
+ };
+ });
+
+ return expect(snapshot);
+ }
+
+ /**
+ * The snapshot of the UI uses some special syntax like `<>` to denote the
+ * selection start (`<`) and end (`>`), as well as some markers for the
+ * currently selected item and history suggestions.
+ */
+ expectUi() {
+ const input = this.inputSnapshot();
+ const suggestions = this.suggestionsSnapshot();
+
+ return expect({ input, suggestions });
+ }
+
+ suggestionsSnapshot() {
+ const { popup } = this;
+
+ if (popup.classList.contains('hidden')) {
+ return [];
+ }
+
+ return [...popup.children].map(el => {
+ if (el.tagName === 'HR') {
+ return '-----------';
+ }
+
+ let content = el.textContent!.trim();
+
+ if (el.classList.contains('autocomplete__item__history')) {
+ content = `(history) ${content}`;
+ }
+
+ if (el.classList.contains('autocomplete__item--selected')) {
+ return `👉 ${content}`;
+ }
+ return content;
+ });
+ }
+
+ inputSnapshot() {
+ const { input } = this;
+
+ const value = [...input.value];
+
+ if (input.selectionStart) {
+ value.splice(input.selectionStart, 0, '<');
+ }
+
+ if (input.selectionEnd) {
+ const shift = input.selectionStart && input.selectionStart <= input.selectionEnd ? 1 : 0;
+
+ value.splice(input.selectionEnd + shift, 0, '>');
+ }
+
+ return value.join('');
+ }
+}
+
+export async function init(): Promise {
+ const fakeAutocompleteBuffer = await fs.promises
+ .readFile(path.join(__dirname, '../../utils/__tests__/autocomplete-compiled-v2.bin'))
+ .then(({ buffer }) => new Response(buffer));
+
+ const ctx = new TestContext(fakeAutocompleteBuffer);
+
+ expect(fetch).not.toHaveBeenCalled();
+
+ // Initialize the lazy autocomplete index cache
+ await ctx.focusInput();
+
+ ctx.expectRequests().toMatchInlineSnapshot(`
+ [
+ {
+ "dest": "GET http://localhost:3000/autocomplete/compiled?vsn=2&key=1970-0-1",
+ "meta": {
+ "cache": "force-cache",
+ "credentials": "omit",
+ },
+ },
+ ]
+ `);
+
+ ctx.expectUi().toMatchInlineSnapshot(`
+ {
+ "input": "",
+ "suggestions": [],
+ }
+ `);
+
+ return ctx;
+}
diff --git a/assets/js/autocomplete/__tests__/history.spec.ts b/assets/js/autocomplete/__tests__/history.spec.ts
new file mode 100644
index 00000000..394442cf
--- /dev/null
+++ b/assets/js/autocomplete/__tests__/history.spec.ts
@@ -0,0 +1,102 @@
+import { init } from './context';
+
+it('records search history', async () => {
+ const ctx = await init();
+
+ await ctx.submitForm('foo1');
+
+ // Empty input should show all latest history items
+ ctx.expectUi().toMatchInlineSnapshot(`
+ {
+ "input": "",
+ "suggestions": [
+ "(history) foo1",
+ ],
+ }
+ `);
+
+ await ctx.submitForm('foo2');
+
+ ctx.expectUi().toMatchInlineSnapshot(`
+ {
+ "input": "",
+ "suggestions": [
+ "(history) foo2",
+ "(history) foo1",
+ ],
+ }
+ `);
+
+ await ctx.submitForm('a complex OR (query AND bar)');
+
+ ctx.expectUi().toMatchInlineSnapshot(`
+ {
+ "input": "",
+ "suggestions": [
+ "(history) a complex OR (query AND bar)",
+ "(history) foo2",
+ "(history) foo1",
+ ],
+ }
+ `);
+
+ // Last recently used item should be on top
+ await ctx.submitForm('foo2');
+
+ ctx.expectUi().toMatchInlineSnapshot(`
+ {
+ "input": "",
+ "suggestions": [
+ "(history) foo2",
+ "(history) a complex OR (query AND bar)",
+ "(history) foo1",
+ ],
+ }
+ `);
+
+ await ctx.setInput('a com');
+
+ ctx.expectUi().toMatchInlineSnapshot(`
+ {
+ "input": "a com<>",
+ "suggestions": [
+ "(history) a complex OR (query AND bar)",
+ ],
+ }
+ `);
+
+ await ctx.setInput('f');
+
+ ctx.expectUi().toMatchInlineSnapshot(`
+ {
+ "input": "f<>",
+ "suggestions": [
+ "(history) foo2",
+ "(history) foo1",
+ "-----------",
+ "forest 3",
+ "fog 1",
+ "force field 1",
+ "flower 1",
+ ],
+ }
+ `);
+
+ // History items must be selectable
+ await ctx.keyDown('ArrowDown');
+
+ ctx.expectUi().toMatchInlineSnapshot(`
+ {
+ "input": "foo2<>",
+ "suggestions": [
+ "👉 (history) foo2",
+ "(history) foo1",
+ "-----------",
+ "forest 3",
+ "fog 1",
+ "force field 1",
+ "flower 1",
+ ],
+ }
+ `);
+});
diff --git a/assets/js/autocomplete/__tests__/keyboard.spec.ts b/assets/js/autocomplete/__tests__/keyboard.spec.ts
new file mode 100644
index 00000000..3c5a0882
--- /dev/null
+++ b/assets/js/autocomplete/__tests__/keyboard.spec.ts
@@ -0,0 +1,114 @@
+import { init } from './context';
+
+it('supports navigation via keyboard', async () => {
+ const ctx = await init();
+
+ await ctx.setInput('f');
+
+ await ctx.keyDown('ArrowDown');
+
+ ctx.expectUi().toMatchInlineSnapshot(`
+ {
+ "input": "forest<>",
+ "suggestions": [
+ "👉 forest 3",
+ "fog 1",
+ "force field 1",
+ "flower 1",
+ ],
+ }
+ `);
+
+ await ctx.keyDown('ArrowDown');
+
+ ctx.expectUi().toMatchInlineSnapshot(`
+ {
+ "input": "fog<>",
+ "suggestions": [
+ "forest 3",
+ "👉 fog 1",
+ "force field 1",
+ "flower 1",
+ ],
+ }
+ `);
+
+ await ctx.keyDown('ArrowDown', { ctrlKey: true });
+
+ ctx.expectUi().toMatchInlineSnapshot(`
+ {
+ "input": "flower<>",
+ "suggestions": [
+ "forest 3",
+ "fog 1",
+ "force field 1",
+ "👉 flower 1",
+ ],
+ }
+ `);
+
+ await ctx.keyDown('ArrowUp', { ctrlKey: true });
+
+ ctx.expectUi().toMatchInlineSnapshot(`
+ {
+ "input": "forest<>",
+ "suggestions": [
+ "👉 forest 3",
+ "fog 1",
+ "force field 1",
+ "flower 1",
+ ],
+ }
+ `);
+
+ await ctx.keyDown('Enter');
+
+ ctx.expectUi().toMatchInlineSnapshot(`
+ {
+ "input": "forest<>",
+ "suggestions": [],
+ }
+ `);
+
+ await ctx.setInput('forest, t<>, safe');
+
+ ctx.expectUi().toMatchInlineSnapshot(`
+ {
+ "input": "forest, t<>, safe",
+ "suggestions": [
+ "artist:test 1",
+ ],
+ }
+ `);
+
+ await ctx.keyDown('ArrowDown');
+
+ ctx.expectUi().toMatchInlineSnapshot(`
+ {
+ "input": "forest, artist:test<>, safe",
+ "suggestions": [
+ "👉 artist:test 1",
+ ],
+ }
+ `);
+
+ await ctx.keyDown('Escape');
+
+ ctx.expectUi().toMatchInlineSnapshot(`
+ {
+ "input": "forest, artist:test<>, safe",
+ "suggestions": [],
+ }
+ `);
+
+ await ctx.setInput('forest, t<>, safe');
+ await ctx.keyDown('ArrowDown');
+ await ctx.keyDown('Enter');
+
+ ctx.expectUi().toMatchInlineSnapshot(`
+ {
+ "input": "forest, artist:test<>, safe",
+ "suggestions": [],
+ }
+ `);
+});
diff --git a/assets/js/autocomplete/__tests__/server-side-completions.spec.ts b/assets/js/autocomplete/__tests__/server-side-completions.spec.ts
new file mode 100644
index 00000000..fcf53eb7
--- /dev/null
+++ b/assets/js/autocomplete/__tests__/server-side-completions.spec.ts
@@ -0,0 +1,48 @@
+import { init } from './context';
+
+it('requests server-side autocomplete if local autocomplete returns no results', async () => {
+ const ctx = await init();
+
+ await ctx.setInput('mar');
+
+ // 1. Request the local autocomplete index.
+ // 2. Request the server-side suggestions.
+ expect(fetch).toHaveBeenCalledTimes(2);
+
+ ctx.expectUi().toMatchInlineSnapshot(`
+ {
+ "input": "mar<>",
+ "suggestions": [
+ "marvelous → beautiful 30",
+ "mare 20",
+ "market 10",
+ ],
+ }
+ `);
+
+ await ctx.setInput('');
+
+ // Make sure the response caching is insensitive to term case and leading whitespace.
+ await ctx.setInput('mar');
+ await ctx.setInput(' mar');
+ await ctx.setInput(' Mar');
+ await ctx.setInput(' MAR');
+
+ ctx.expectUi().toMatchInlineSnapshot(`
+ {
+ "input": " MAR<>",
+ "suggestions": [
+ "marvelous → beautiful 30",
+ "mare 20",
+ "market 10",
+ ],
+ }
+ `);
+
+ expect(fetch).toHaveBeenCalledTimes(2);
+
+ // Trailing whitespace is still significant because terms may have internal spaces.
+ await ctx.setInput('mar ');
+
+ expect(fetch).toHaveBeenCalledTimes(3);
+});