TauriTavern/tests/browser/popup.test.mjs
Darkatse 311287205d feat(a11y): unify character and chat list interactions
Give character, persona, group and chat lists native actions, accessible
names and state, and shared focus, pagination and menu behavior. Update
Font Awesome 6 assets and keep icon fonts independent of text fonts.

Bind chat file actions to their owners, report failures, and preserve
extension-facing chat exports and character-index parameters. Replace
legacy pagination helpers with one initializer and retain focused tests.

Compatibility: deleting the open chat loads a replacement. Discordia
0.7.3's subsequent clearChat flow is unsupported; Folder Manager 3.0.15
may leave an extra new chat after deleting its last current chat. Both
boundaries are documented and accepted for this change.

Removed exports: PAGINATION_TEMPLATE, localizePagination,
renderPaginationDropdown, paginationDropdownChangeHandler. The
jumpToNewChat option is removed from deleteGroupChat.

Validation: pnpm run check; targeted extension-call and Chromium probes.
2026-10-09 18:25:33 -04:00

243 lines
12 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { computeAccessibleName } from 'dom-accessibility-api';
import { createBrowserRuntime } from './runtime.mjs';
test('Popup naming and result controls', async context => {
const { window, getModule, load, startHost } = createBrowserRuntime();
try {
await startHost();
await load('script.js');
const { Popup, POPUP_TYPE, POPUP_RESULT } = getModule('scripts/popup.js').namespace;
const { power_user, send_on_enter_options } = getModule('scripts/power-user.js').namespace;
getModule('scripts/keyboard.js').namespace.initKeyboard();
const { document } = window;
power_user.send_on_enter = send_on_enter_options.ENABLED;
await context.test('expanded editors write through native and jQuery input handlers', async () => {
getModule('scripts/chats.js').namespace.initChatUtilities();
const fixture = document.createElement('div');
fixture.innerHTML = '<div id="expanded-editor-source" contenteditable="true"></div><button type="button" class="editor_maximize" data-for="expanded-editor-source"></button>';
document.body.append(fixture);
const source = fixture.querySelector('[contenteditable]');
const values = {};
source.addEventListener('input', () => { values.native = source.innerText; });
window.jQuery(source).on('input', () => { values.jquery = source.innerText; });
const click = window.jQuery.Event('click');
window.jQuery(fixture.querySelector('button')).trigger(click);
const popup = Popup.util.popups.at(-1);
try {
const editor = popup.dlg.querySelector('textarea.maximized_textarea');
editor.value = 'Edited greeting';
editor.dispatchEvent(new window.Event('input', { bubbles: true }));
assert.deepEqual(values, { native: 'Edited greeting', jquery: 'Edited greeting' });
} finally {
await popup.completeAffirmative();
await click.result;
fixture.remove();
}
});
await context.test('extension menus let Tab leave, consume Escape and hand dialogs a visible focus origin', async () => {
await getModule('scripts/extensions.js').namespace.ensureExtensionsUiReady();
const trigger = document.getElementById('extensionsMenuButton');
const panel = document.getElementById('extensionsMenu');
const action = document.createElement('button');
action.textContent = 'Edit extension';
panel.append(action);
let shown;
let focusAtOpen;
action.addEventListener('click', () => {
focusAtOpen = document.activeElement;
shown = Popup.show.input('Extension setting', '', '', { animation: 'none' });
});
trigger.click();
assert.equal(document.activeElement, panel);
const input = document.getElementById('send_textarea');
input.focus();
assert.equal(trigger.getAttribute('aria-expanded'), 'true', 'focus may leave without dismissing the menu');
action.focus();
let escapedToDocument = false;
const onEscape = () => { escapedToDocument = true; };
document.addEventListener('keydown', onEscape);
const escape = new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
action.dispatchEvent(escape);
document.removeEventListener('keydown', onEscape);
assert.equal(escape.defaultPrevented, true);
assert.equal(escapedToDocument, false);
assert.equal(document.activeElement, trigger);
trigger.click();
action.focus();
action.click();
const popup = Popup.util.popups.at(-1);
assert.equal(panel.style.display, 'none');
// Native dialog autofocus/restoration is verified in WebView.
assert.equal(focusAtOpen, trigger);
assert.equal(popup.dlg.open, true);
await popup.completeCancelled();
await shown;
trigger.click();
input.focus();
input.click();
assert.equal(panel.style.display, 'none');
assert.equal(document.activeElement, input, 'outside clicks keep their own focus');
});
async function enter(control, options = {}, cancelled = false) {
control.focus();
const event = new window.KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true, ...options });
if (cancelled) event.preventDefault();
control.dispatchEvent(event);
// A synchronous onClosing rejection resumes complete() in this microtask.
await Promise.resolve();
return event;
}
await context.test('simultaneous dialogs keep their own accessible name when a title changes', async () => {
const pending = [
Popup.show.input('Rename <em>device</em>', 'Input help', '', { animation: 'none' }),
Popup.show.confirm('Confirm change', '<h2>Body heading</h2>', { animation: 'none' }),
];
const popups = Array.from(Popup.util.popups);
assert.deepEqual(popups.map(popup => computeAccessibleName(popup.dlg)), ['Rename device', 'Confirm change']);
popups[0].content.firstElementChild.textContent = '重命名设备';
assert.deepEqual(popups.map(popup => computeAccessibleName(popup.dlg)), ['重命名设备', 'Confirm change']);
for (const popup of popups.toReversed()) await popup.completeCancelled();
await Promise.all(pending);
});
await context.test('action-only and cancelled buttons use click without coercing their results', async () => {
let actions = 0;
const popup = new Popup('Actions', POPUP_TYPE.TEXT, '', {
animation: 'none',
customButtons: [
{ text: 'Preview', action: () => actions++ },
{ text: 'Go back', result: null },
],
});
const shown = popup.show();
const [preview, back] = popup.buttonControls.querySelectorAll('.popup-button-custom');
const key = await enter(preview);
// happy-dom cannot synthesize a browser's default activation. Popup must leave it alone.
assert.equal(key.defaultPrevented, false);
assert.equal(popup.result, undefined);
preview.click();
assert.equal(actions, 1);
assert.equal(popup.dlg.open, true);
assert.equal(popup.result, undefined);
back.click();
assert.equal(await shown, null);
const actionGate = Promise.withResolvers();
const actionDone = Promise.withResolvers();
let actionFinished = false;
const numeric = new Popup('Result', POPUP_TYPE.TEXT, '', {
animation: 'none',
customButtons: [{ text: 'Choose', result: 1001, action: async () => {
await actionGate.promise;
actionFinished = true;
actionDone.resolve();
} }],
});
const numericShown = numeric.show();
numeric.buttonControls.querySelector('.popup-button-custom').click();
assert.equal(await numericShown, 1001);
assert.equal(actionFinished, false, 'automatic completion does not start awaiting custom actions');
actionGate.resolve();
await actionDone.promise;
});
await context.test('extension disabled state blocks Popup actions and input submission while allowing recovery', async () => {
let actions = 0;
const popup = new Popup('<div class="disabled"><button type="button">Independent action</button></div>', POPUP_TYPE.INPUT, 'value', {
animation: 'none', customButtons: [{ text: 'Preview', action: () => actions++ }],
});
const shown = popup.show();
const preview = popup.buttonControls.querySelector('.popup-button-custom');
preview.classList.add('disabled');
popup.okButton.setAttribute('aria-disabled', 'true');
preview.click();
await enter(popup.mainInput);
assert.equal(actions, 0);
assert.equal(popup.dlg.open, true);
let independentActions = 0;
const independent = popup.content.querySelector('button');
independent.addEventListener('click', () => independentActions++);
independent.click();
assert.equal(independentActions, 1);
preview.classList.remove('disabled');
preview.click();
assert.equal(actions, 1);
await popup.completeAffirmative();
assert.equal(await shown, 'value');
});
await context.test('dynamic async close rejection keeps the original result and allows retry', async () => {
const popup = new Popup('Validate', POPUP_TYPE.CONFIRM, '', { animation: 'none' });
const shown = popup.show();
const validation = Promise.withResolvers();
const results = [];
popup.onClosing = async current => {
results.push(current.result);
return await validation.promise;
};
const attempt = popup.completeAffirmative();
popup.cancelButton.click();
assert.deepEqual(results, [POPUP_RESULT.AFFIRMATIVE]);
assert.equal(popup.result, POPUP_RESULT.AFFIRMATIVE);
validation.resolve(false);
assert.equal(await attempt, undefined);
assert.equal(popup.result, undefined);
assert.equal(popup.dlg.open, true);
popup.onClosing = current => current.result === POPUP_RESULT.CANCELLED;
popup.dlg.dispatchEvent(new window.Event('cancel', { cancelable: true }));
assert.equal(await shown, null);
});
await context.test('input Enter respects editing rules and legacy result actions retain their click', async () => {
const legacy = document.createElement('div');
legacy.className = 'result-control menu_button';
legacy.tabIndex = 0;
legacy.dataset.result = '1001';
let actions = 0;
legacy.addEventListener('click', () => actions++);
const popup = new Popup(legacy, POPUP_TYPE.INPUT, '', { rows: 2, animation: 'none' });
const shown = popup.show();
const results = [];
popup.onClosing = current => { results.push(current.result); return false; };
await enter(popup.mainInput);
await enter(popup.mainInput, { ctrlKey: true, isComposing: true });
await enter(popup.mainInput, { ctrlKey: true }, true);
assert.deepEqual(results, []);
await enter(popup.mainInput, { ctrlKey: true });
assert.deepEqual(results, [1]);
power_user.send_on_enter = send_on_enter_options.DISABLED;
await enter(popup.mainInput, { ctrlKey: true });
assert.deepEqual(results, [1]);
power_user.send_on_enter = send_on_enter_options.ENABLED;
popup.mainInput.rows = 1;
popup.mainInput.dataset.result = 'undefined';
await enter(popup.mainInput);
assert.deepEqual(results, [1]);
popup.mainInput.dataset.result = 'null';
await enter(popup.mainInput);
assert.deepEqual(results, [1, null]);
popup.mainInput.classList.remove('result-control');
await enter(popup.mainInput);
assert.deepEqual(results, [1, null]);
await enter(legacy);
assert.equal(actions, 1);
assert.deepEqual(results, [1, null, 1001]);
popup.onClosing = null;
await popup.completeCancelled();
await shown;
});
} finally {
await window.happyDOM.close();
}
});