diff --git a/README.md b/README.md
index a23cfa2..a62aee1 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,7 @@ From a clean checkout of the intended production commit:
```bash
python3 -m unittest discover -s scripts -p 'test_*.py' -v
+node --test scripts/test_catalog_search_events.cjs
python3 scripts/diagnose_growth_deploy.py --root . --output growth-deploy-diagnosis.json
python3 scripts/diagnose_growth_routes.py --output growth-route-diagnosis.json
python3 scripts/check_growth_live.py > growth-live-smoke.json
diff --git a/apps.html b/apps.html
index 78cb72f..d84a5ce 100644
--- a/apps.html
+++ b/apps.html
@@ -1473,7 +1473,11 @@
Chaos - Stranger Missions
});
lastReportedSearch = query;
};
- input.addEventListener('input', render);
+ input.addEventListener('input', () => {
+ // The browser search clear (×) must allow an identical query next time.
+ if (!clean(input.value)) lastReportedSearch = '';
+ render();
+ });
input.addEventListener('blur', reportSearch);
input.addEventListener('keydown', event => {
if (event.key === 'Enter') reportSearch();
diff --git a/scripts/test_catalog_search_events.cjs b/scripts/test_catalog_search_events.cjs
new file mode 100644
index 0000000..7b66ad2
--- /dev/null
+++ b/scripts/test_catalog_search_events.cjs
@@ -0,0 +1,93 @@
+'use strict';
+// Run from repository root: node --test scripts/test_catalog_search_events.cjs
+// Execute the actual catalog's input/reporting handlers in an isolated VM.
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const vm = require('node:vm');
+
+const html = fs.readFileSync(path.join(__dirname, '..', 'apps.html'), 'utf8');
+const start = html.indexOf(' const reportSearch = () => {');
+const after = html.indexOf(" input.addEventListener('keydown', event => {", start);
+const end = html.indexOf(' });', after) + ' });'.length;
+assert.ok(start >= 0 && after > start && end > after, 'search handlers are present');
+const handlers = html.slice(start, end);
+
+function setup() {
+ const events = [];
+ const listeners = {};
+ const input = {
+ value: '',
+ addEventListener(type, callback) { listeners[type] = callback; },
+ };
+ const context = {
+ input,
+ clean: v => v.normalize('NFKC').toLocaleLowerCase().trim(),
+ render: () => 4,
+ fire: (name, payload) => events.push({ name, ...payload }),
+ };
+ vm.runInNewContext("let lastReportedSearch = '';\n" + handlers + "\nglobalThis.commitSearch = reportSearch;", context);
+ assert.equal(typeof listeners.input, 'function');
+ assert.equal(typeof listeners.blur, 'function');
+ assert.equal(typeof listeners.keydown, 'function');
+ return { input, events, listeners, commit: context.commitSearch };
+}
+
+test('typing does not send an unfinished search', () => {
+ const h = setup();
+ h.input.value = 'do';
+ h.listeners.input();
+ assert.equal(h.events.length, 0);
+});
+
+test('Enter emits exactly one committed search', () => {
+ const h = setup();
+ h.input.value = 'dotchi';
+ h.listeners.input();
+ h.listeners.keydown({ key: 'Enter' });
+ h.listeners.blur();
+ assert.equal(h.events.length, 1);
+ assert.equal(h.events[0].name, 'catalog_search_used');
+ assert.equal(h.events[0].results, 4);
+ assert.equal(h.events[0].search_length_band, 'long');
+});
+
+test('browser search clear allows the same query to count again', () => {
+ const h = setup();
+ h.input.value = 'dotchi';
+ h.listeners.input();
+ h.listeners.blur();
+ h.input.value = '';
+ h.listeners.input();
+ h.input.value = 'dotchi';
+ h.listeners.input();
+ h.listeners.blur();
+ assert.equal(h.events.length, 2);
+});
+
+test('clearing a search does not emit a blank query', () => {
+ const h = setup();
+ h.input.value = ' ';
+ h.listeners.input();
+ h.listeners.blur();
+ assert.equal(h.events.length, 0);
+});
+
+test('repeated blur without a cleared search is deduplicated', () => {
+ const h = setup();
+ h.input.value = 'sleep';
+ h.listeners.input();
+ h.listeners.blur();
+ h.listeners.blur();
+ assert.equal(h.events.length, 1);
+});
+
+test('committed searches never send raw query text', () => {
+ const h = setup();
+ h.input.value = 'sensitive private query';
+ h.listeners.input();
+ h.listeners.blur();
+ assert.equal(h.events.length, 1);
+ assert.equal(JSON.stringify(h.events).includes('sensitive'), false);
+});