screentinker/server/test/directory-search.test.js
screentinker 702e107972
Directory search: don't let the platform keyboard cover our own (#275)
The directory-search widget draws its own on-screen keyboard, on by default, sized
and themed to the panel. On Android it was never visible: the page autofocuses a
real <input>, which is the signal to raise the system IME, and that lands over the
bottom of the screen — exactly where our keyboard is.

So a directory panel showed Google's keyboard instead of the one the widget ships:
split across a 1920x1080 screen, with mic, GIF, emoji, clipboard and a settings key
that opens Google's own UI on a kiosk. On the panel that turned this up, the system
keyboard WAS voice input — the only enabled IME was Google's voice IME, so touching
the search box opened a microphone, on a wall-mounted tenant directory.

When we draw a keyboard, the input now carries inputmode="none", so the platform
leaves its keyboard down. The buttons write input.value directly, so nothing about
typing changes. Browsers that don't know inputmode ignore it, which is the right
fallback — a desktop preview behaves exactly as before.

Gated on the flag, not applied to the markup: with show_onscreen_keyboard off there
is nothing to cover, and the platform keyboard is the only way left to type.

Verified by removing the line and watching the new guard fail. 1669/1669 pass.
2026-08-14 08:59:31 -05:00

172 lines
9.1 KiB
JavaScript

'use strict';
// directory-search widget: references a directory-board by id and renders an
// interactive walk-up search page. Verifies the source board's entries are
// safely inlined for client-side filtering, that a missing/wrong source shows a
// friendly fallback (not a 500), and that entry/category text can't break out
// of the inlined <script> (it's set via textContent at runtime).
const test = require('node:test');
const assert = require('node:assert/strict');
const Database = require('better-sqlite3');
process.env.JWT_SECRET = 'test-secret-dirsearch';
const db = new Database(':memory:');
db.exec(`CREATE TABLE widgets (id TEXT PRIMARY KEY, widget_type TEXT, config TEXT, workspace_id TEXT);`);
const dbModulePath = require.resolve('../db/database');
require.cache[dbModulePath] = { id: dbModulePath, filename: dbModulePath, loaded: true, exports: { db } };
const express = require('express');
const widgetsRouter = require('../routes/widgets');
const app = express();
app.use('/api/widgets', widgetsRouter);
const server = app.listen(0);
let base;
test.before(async () => { await new Promise(r => server.listening ? r() : server.once('listening', r)); base = `http://127.0.0.1:${server.address().port}`; });
test.after(() => { server.close(); db.close(); });
const seed = (id, type, config) => db.prepare('INSERT INTO widgets (id, widget_type, config, workspace_id) VALUES (?,?,?,?)').run(id, type, JSON.stringify(config), 'ws1');
const fetchRender = async (id) => { const r = await fetch(`${base}/api/widgets/${id}/render`); return { status: r.status, html: await r.text() }; };
const BOARD = {
title: 'Lincoln Warehouse',
categories: [
{ name: 'First Floor', entries: [
{ identifier: '101', name: 'Acme Co', subtitle: 'Suite A', available: false },
{ identifier: '102', name: 'Available Unit', subtitle: '', available: true },
] },
{ name: 'Second Floor', entries: [
{ identifier: '201', name: 'Globex', subtitle: 'Logistics', available: false },
] },
],
};
test('directory-search renders a search page and inlines the source board entries', async () => {
seed('board1', 'directory-board', BOARD);
seed('search1', 'directory-search', { source_widget_id: 'board1', title: 'Find a Tenant', show_onscreen_keyboard: true });
const { status, html } = await fetchRender('search1');
assert.equal(status, 200);
assert.ok(html.includes('id="q"'), 'has a search input');
assert.ok(html.includes('id="results"'), 'has a results container');
assert.ok(html.includes('Acme Co') && html.includes('Globex') && html.includes('101'), 'source entries embedded for client-side filtering');
assert.ok(html.includes('Find a Tenant'), 'search title present');
});
test('show_onscreen_keyboard flag is carried into the page config', async () => {
seed('search_kb_off', 'directory-search', { source_widget_id: 'board1', show_onscreen_keyboard: false });
const { html } = await fetchRender('search_kb_off');
assert.ok(html.includes('"show_onscreen_keyboard":false'), 'keyboard flag inlined (page hides the keyboard when false)');
});
test('the built-in keyboard suppresses the platform one', async () => {
// The page autofocuses a real <input>, which on Android raises the system IME over the
// bottom of the screen - covering the keyboard this widget draws itself. A panel showed
// Gboard, complete with a mic key, and never showed its own keyboard.
seed('search_kb_on', 'directory-search', { source_widget_id: 'board1', show_onscreen_keyboard: true });
const { html } = await fetchRender('search_kb_on');
assert.ok(/setAttribute\(\s*'inputmode'\s*,\s*'none'\s*\)/.test(html),
'page tells the platform not to raise its own keyboard');
// Only when we are drawing one. With the built-in keyboard off there is nothing to cover,
// and the platform keyboard is the only way left to type.
seed('search_kb_off2', 'directory-search', { source_widget_id: 'board1', show_onscreen_keyboard: false });
const off = await fetchRender('search_kb_off2');
assert.ok(off.html.includes('"show_onscreen_keyboard":false'), 'flag inlined as false');
assert.ok(!/inputmode="none"/.test(off.html),
'the input is not statically marked inputmode=none - suppression is gated on the flag at runtime');
});
test('missing source -> friendly fallback page, not a 500', async () => {
seed('search_missing', 'directory-search', { source_widget_id: 'does-not-exist' });
const { status, html } = await fetchRender('search_missing');
assert.equal(status, 200);
assert.ok(html.includes('Directory source not found'), 'friendly message shown instead of an error');
});
test('non-directory-board source -> friendly fallback page', async () => {
seed('clockX', 'clock', {});
seed('search_wrongtype', 'directory-search', { source_widget_id: 'clockX' });
const { status, html } = await fetchRender('search_wrongtype');
assert.equal(status, 200);
assert.ok(html.includes('Directory source not found'), 'friendly message for a wrong source type');
});
test('XSS: entry/category text cannot break out of the inlined script', async () => {
seed('board_xss', 'directory-board', {
categories: [{
name: '</script><script>window.__pwned=1</script>',
entries: [{ identifier: '<img src=x onerror=alert(1)>', name: '"><b>bold</b>', subtitle: 'amp & lt < gt >', available: false }],
}],
});
seed('search_xss', 'directory-search', { source_widget_id: 'board_xss' });
const { status, html } = await fetchRender('search_xss');
assert.equal(status, 200);
assert.ok(!html.includes('</script><script>window.__pwned'), 'raw </script> breakout neutralized');
assert.ok(html.includes('\\u003c/script>'), 'angle brackets escaped in the inlined JSON blob');
});
// ---- live sync: GET /:id/data.json feed the search page polls ----
const fetchData = async (id) => fetch(`${base}/api/widgets/${id}/data.json`);
test('data.json returns the source board categories, CORS-open for polling', async () => {
const r = await fetchData('board1'); // seeded in the first test
assert.equal(r.status, 200);
assert.equal(r.headers.get('access-control-allow-origin'), '*', 'readable from a null-origin sandboxed iframe');
assert.equal(r.headers.get('cache-control'), 'no-store');
const body = await r.json();
assert.ok(Array.isArray(body.categories) && body.categories.length === 2, 'returns the categories array');
assert.equal(body.categories[0].entries[0].name, 'Acme Co');
});
test('data.json 404s for a missing widget (poll keeps last-good data)', async () => {
assert.equal((await fetchData('does-not-exist')).status, 404);
});
test('data.json 404s for a non-directory-board widget', async () => {
assert.equal((await fetchData('clockX')).status, 404); // clockX seeded earlier
});
test('search page wires the live-sync poll to its source board', async () => {
const { html } = await fetchRender('search1');
assert.ok(html.includes('"source_widget_id":"board1"'), 'source board id inlined into the page');
assert.ok(html.includes('/data.json'), 'page polls the data.json feed');
});
// The on-screen keyboard must be sized against the VIEWPORT, not in fixed px. A panel's CSS
// viewport is its resolution over its density, so a 1080p screen at 240dpi presents 1280x720 CSS
// px — where a keyboard laid out for 1920x1080 ate ~37% of the height instead of ~24%. The clamp()
// maxima are the ORIGINAL fixed values, so a 1080-tall viewport must stay pixel-identical.
test('the on-screen keyboard scales with the viewport instead of using fixed pixels', async () => {
const bid = 'kb-board', sid = 'kb-search';
seed(bid, 'directory-board', BOARD);
seed(sid, 'directory-search', { source_widget_id: bid });
const { status, html } = await fetchRender(sid);
assert.equal(status, 200);
const keyRule = html.match(/\.key\s*\{[^}]*\}/s);
assert.ok(keyRule, '.key rule is present');
assert.match(keyRule[0], /height:clamp\(/, 'key height is clamped to the viewport');
assert.match(keyRule[0], /font-size:clamp\(/, 'key font-size is clamped to the viewport');
assert.ok(!/height:\s*56px/.test(keyRule[0]), 'no bare fixed height survives');
// vh terms must exceed their max at 1080 tall, so existing 1080 panels render unchanged.
const h = keyRule[0].match(/height:clamp\(([\d.]+)px,\s*([\d.]+)vh,\s*([\d.]+)px\)/);
assert.ok(h, 'height clamp is well-formed');
const [, hMin, hVh, hMax] = h.map(Number);
assert.ok(hVh * 1080 / 100 >= hMax, 'at a 1080-tall viewport the height clamps to its max (no visual change)');
assert.ok(hVh * 720 / 100 < hMax, 'at a 720-tall viewport the height actually scales down');
assert.ok(hMin >= 30, 'keys stay tappable on very short viewports');
});
test('the narrow breakpoint no longer pins the key size back to fixed pixels', async () => {
const bid = 'kb2-board', sid = 'kb2-search';
seed(bid, 'directory-board', BOARD);
seed(sid, 'directory-search', { source_widget_id: bid });
const { html } = await fetchRender(sid);
const mq = html.match(/@media \(max-width:700px\)\s*\{[^}]*\}[^}]*\}/s);
assert.ok(mq, 'the narrow breakpoint still exists');
assert.ok(!/\.key\s*\{[^}]*height:\s*\d+px/.test(mq[0]),
'the breakpoint must not re-pin .key to a fixed height and undo the clamp');
});