mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
xterm's DOM renderer rewrites its rows (`.xterm-rows > div`) on every frame a pane changes, and with a grid of tiles that is every record the i18n observer gets (about 4,600 a second for six printing tiles, all of them row rewrites). Each paid one closest() over the whole skip selector list (the per-record skip of21beacf7, which stays). Every row of one terminal shares its parent, so that parent's own shouldSkip() verdict is now kept once it says skip: same verdict, one closest() per terminal instead of one per record. A rows container outside any skipped surface keeps the full check, so nothing that was translated stops being translated. Measured (6 printing tiles, 30 s profiles, n=3 interleaved A/B, load 5.8 to 9.5, equivalent class-check variant): observer 391 to 445 ms -> 47 to 52 ms per 30 s in English, 473 -> 58 ms in zh-CN; main-thread script time -0.35 s per 30 s (-14%). Frame share unchanged within noise. Live, this code (6 printing tiles, 30 s profiles, interleaved against the file at00440c02, load 4.4 to 6.6): observer 417 to 430 ms -> 56 to 58 ms per 30 s; script time 2.63 to 2.79 s -> 2.24 s in the undisturbed window. The other window of this code was disturbed by CPU contention on the box (every rendering cost 3 to 4 times higher, xterm's own included, 351 frames in 30 s) and is not counted; its observer time was 56 ms all the same. Test: 60 row rewrites in a terminal cost one closest(), the rows stay untranslated, and a stray `.xterm-rows` outside any skip surface is still translated (the verdict closest() gives); mutation-checked both ways. Scope: PR 2 (i18n, on top of21beacf7). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
230 lines
11 KiB
TypeScript
230 lines
11 KiB
TypeScript
/**
|
|
* Regression coverage for the browser i18n layer and custom display-name setting.
|
|
* Port: N/A (JSDOM + direct schema/server render calls only).
|
|
*/
|
|
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import vm from 'node:vm';
|
|
import { JSDOM } from 'jsdom';
|
|
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
|
import { WebServer } from '../src/web/server.js';
|
|
|
|
const i18nSource = readFileSync(new URL('../src/web/public/i18n.js', import.meta.url), 'utf8');
|
|
const indexSource = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf8');
|
|
const settingsSource = readFileSync(new URL('../src/web/public/settings-ui.js', import.meta.url), 'utf8');
|
|
|
|
function makeDom(body = '<span class="logo">Codeman</span><button title="App Settings">App Settings</button>') {
|
|
const dom = new JSDOM(
|
|
`<!doctype html><html><head><title>codeman:test-host</title></head><body>${body}</body></html>`,
|
|
{
|
|
runScripts: 'outside-only',
|
|
url: 'http://localhost/',
|
|
}
|
|
);
|
|
vm.runInContext(i18nSource, dom.getInternalVMContext(), { filename: 'i18n.js' });
|
|
return dom;
|
|
}
|
|
|
|
describe('custom display name and browser localization', () => {
|
|
const previousDataDir = process.env.CODEMAN_DATA_DIR;
|
|
|
|
afterEach(() => {
|
|
if (previousDataDir === undefined) delete process.env.CODEMAN_DATA_DIR;
|
|
else process.env.CODEMAN_DATA_DIR = previousDataDir;
|
|
});
|
|
|
|
it('accepts bounded Chinese display names and rejects invalid values', () => {
|
|
expect(SettingsUpdateSchema.safeParse({ displayName: '小码助手' }).success).toBe(true);
|
|
expect(SettingsUpdateSchema.safeParse({ displayName: ' 小码助手 ' }).data).toMatchObject({
|
|
displayName: '小码助手',
|
|
});
|
|
expect(SettingsUpdateSchema.safeParse({ displayName: '' }).success).toBe(false);
|
|
expect(SettingsUpdateSchema.safeParse({ displayName: '名'.repeat(41) }).success).toBe(false);
|
|
expect(SettingsUpdateSchema.safeParse({ displayName: 'unsafe\nname' }).success).toBe(false);
|
|
});
|
|
|
|
it('translates static and dynamic UI, restores English, and leaves terminal/user content untouched', async () => {
|
|
const dom = makeDom(`
|
|
<span class="logo">Codeman</span>
|
|
<button id="settings" title="App Settings">App Settings</button>
|
|
<div id="user" class="session-tab-name">Home</div>
|
|
<div class="xterm">Run</div>
|
|
<div class="history-item" title="Run">
|
|
<span class="history-item-title">Run</span>
|
|
<span class="history-item-subtitle">Home</span>
|
|
<span class="history-detail-prompt">Settings saved</span>
|
|
<span class="history-detail-path">Home</span>
|
|
</div>
|
|
<div id="dynamic"></div>
|
|
`);
|
|
const { window } = dom;
|
|
const api = window.CodemanI18n;
|
|
api.start();
|
|
api.configure({ language: 'zh-CN', displayName: '小码助手' });
|
|
|
|
expect(window.document.querySelector('.logo')?.textContent).toBe('小码助手');
|
|
expect(window.document.getElementById('settings')?.textContent).toBe('应用设置');
|
|
expect(window.document.getElementById('settings')?.getAttribute('title')).toBe('应用设置');
|
|
expect(window.document.getElementById('user')?.textContent).toBe('Home');
|
|
expect(window.document.querySelector('.xterm')?.textContent).toBe('Run');
|
|
expect(window.document.querySelector('.history-item-title')?.textContent).toBe('Run');
|
|
expect(window.document.querySelector('.history-detail-prompt')?.textContent).toBe('Settings saved');
|
|
expect(window.document.querySelector('.history-item')?.getAttribute('title')).toBe('Run');
|
|
expect(window.document.title).toBe('小码助手:test-host');
|
|
|
|
const dynamicButton = window.document.createElement('button');
|
|
dynamicButton.textContent = 'Settings saved';
|
|
dynamicButton.title = 'Open Codeman across all displays';
|
|
window.document.getElementById('dynamic')?.appendChild(dynamicButton);
|
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
|
expect(dynamicButton.textContent).toBe('设置已保存');
|
|
expect(dynamicButton.title).toBe('在所有显示器上打开 小码助手');
|
|
|
|
api.configure({ language: 'en', displayName: 'Workbench' });
|
|
expect(window.document.getElementById('settings')?.textContent).toBe('App Settings');
|
|
expect(window.document.querySelector('.logo')?.textContent).toBe('Workbench');
|
|
expect(dynamicButton.textContent).toBe('Settings saved');
|
|
expect(window.document.title).toBe('Workbench:test-host');
|
|
dom.window.close();
|
|
});
|
|
|
|
it('does not walk what a terminal renders: one check per mutation inside a skipped surface', async () => {
|
|
const dom = makeDom('<div class="xterm"><div class="xterm-rows" id="rows"></div></div><div id="app"></div>');
|
|
const { window } = dom;
|
|
const api = window.CodemanI18n;
|
|
api.start();
|
|
api.configure({ language: 'zh-CN' });
|
|
// i18n.js also starts itself on DOMContentLoaded (one walk of the body).
|
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
|
let walks = 0;
|
|
const createTreeWalker = window.document.createTreeWalker.bind(window.document);
|
|
window.document.createTreeWalker = (...args: Parameters<Document['createTreeWalker']>) => {
|
|
walks++;
|
|
return createTreeWalker(...args);
|
|
};
|
|
|
|
// What xterm's DOM renderer does every frame: replace the rows.
|
|
const rows = window.document.getElementById('rows')!;
|
|
for (let frame = 0; frame < 3; frame++) {
|
|
const fresh = Array.from({ length: 20 }, () => {
|
|
const row = window.document.createElement('div');
|
|
row.innerHTML = '<span>Settings saved</span><span title="Run">Run</span>';
|
|
return row;
|
|
});
|
|
rows.replaceChildren(...fresh);
|
|
}
|
|
rows.firstElementChild!.firstChild!.firstChild!.nodeValue = 'Settings saved';
|
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
|
expect(walks).toBe(0);
|
|
expect(rows.firstElementChild?.textContent).toBe('Settings savedRun');
|
|
expect(rows.querySelector('[title]')?.getAttribute('title')).toBe('Run');
|
|
|
|
// Application DOM next to it is still translated.
|
|
const button = window.document.createElement('button');
|
|
button.textContent = 'Settings saved';
|
|
window.document.getElementById('app')!.appendChild(button);
|
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
|
expect(button.textContent).toBe('设置已保存');
|
|
expect(walks).toBe(1);
|
|
dom.window.close();
|
|
});
|
|
|
|
it('decides an xterm row by its rows container, with the verdict closest() gives', async () => {
|
|
// A terminal (rows inside .xterm) and a stray `.xterm-rows` outside any
|
|
// skipped surface, which the full check translates.
|
|
const dom = makeDom(
|
|
'<div class="xterm"><div class="xterm-screen"><div class="xterm-rows" id="rows"></div></div></div>' +
|
|
'<div class="xterm-rows" id="stray"></div>'
|
|
);
|
|
const { window } = dom;
|
|
const api = window.CodemanI18n;
|
|
api.start();
|
|
api.configure({ language: 'zh-CN' });
|
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
|
const rowsOf = (id: string) =>
|
|
Array.from({ length: 20 }, () => {
|
|
const row = window.document.createElement('div');
|
|
window.document.getElementById(id)!.appendChild(row);
|
|
return row;
|
|
});
|
|
const rows = rowsOf('rows');
|
|
const stray = rowsOf('stray');
|
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
|
let closest = 0;
|
|
const original = window.Element.prototype.closest;
|
|
window.Element.prototype.closest = function (this: Element, selector: string) {
|
|
closest++;
|
|
return original.call(this, selector);
|
|
};
|
|
|
|
// What xterm's DOM renderer does every frame: replace each row's children.
|
|
const frames = (list: Element[]) => {
|
|
for (let frame = 0; frame < 3; frame++) {
|
|
for (const row of list) {
|
|
const span = window.document.createElement('span');
|
|
span.textContent = 'Settings saved';
|
|
row.replaceChildren(span);
|
|
}
|
|
}
|
|
};
|
|
frames(rows);
|
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
|
// 60 row records, ONE closest(): the container's own verdict, then kept.
|
|
expect(closest).toBe(1);
|
|
expect(rows.every((row) => row.textContent === 'Settings saved')).toBe(true);
|
|
|
|
// A rows container outside any skipped surface keeps the full check, and
|
|
// with it the verdict closest() gives: its rows are translated.
|
|
frames(stray);
|
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
|
window.Element.prototype.closest = original;
|
|
expect(stray.every((row) => row.textContent === '设置已保存')).toBe(true);
|
|
dom.window.close();
|
|
});
|
|
|
|
it('keeps a quoted group name apart from the fixed "Move to" entries in zh-CN', () => {
|
|
const dom = makeDom('');
|
|
const api = dom.window.CodemanI18n;
|
|
api.configure({ language: 'zh-CN' });
|
|
const labels = ['Move to "New group"', 'Move to new group', 'Move to "ungrouped"', 'Move to Ungrouped'].map((label) =>
|
|
api.t(label)
|
|
);
|
|
expect(labels).toEqual(['移到“New group”', '移到新分组', '移到“ungrouped”', '移到未分组']);
|
|
expect(new Set(labels).size).toBe(4);
|
|
dom.window.close();
|
|
});
|
|
|
|
it('renders hostile-looking names as text rather than HTML', () => {
|
|
const dom = makeDom('<span class="logo">Codeman</span>');
|
|
const api = dom.window.CodemanI18n;
|
|
api.start();
|
|
api.configure({ displayName: '<img src=x onerror=alert(1)>', language: 'en' });
|
|
const logo = dom.window.document.querySelector('.logo');
|
|
expect(logo?.textContent).toBe('<img src=x onerror=alert(1)>');
|
|
expect(logo?.querySelector('img')).toBeNull();
|
|
dom.window.close();
|
|
});
|
|
|
|
it('wires the two settings with language kept per-device and display name synced', () => {
|
|
expect(indexSource).toContain('id="appSettingsDisplayName"');
|
|
expect(indexSource).toContain('id="appSettingsLanguage"');
|
|
expect(indexSource).toContain('<option value="zh-CN">简体中文</option>');
|
|
expect(settingsSource).toMatch(/language:\s*_language/);
|
|
expect(settingsSource).toContain("'language',");
|
|
expect(settingsSource).not.toMatch(/displayName:\s*_displayName/);
|
|
});
|
|
|
|
it('uses the escaped custom name in the server-rendered window title', async () => {
|
|
const dataDir = mkdtempSync(join(tmpdir(), 'codeman-brand-title-'));
|
|
process.env.CODEMAN_DATA_DIR = dataDir;
|
|
writeFileSync(join(dataDir, 'settings.json'), JSON.stringify({ displayName: '小码<&助手' }));
|
|
const server = new WebServer(0, false, true, '127.0.0.1', 'laptop');
|
|
const html = await (server as unknown as { renderIndexHtml: () => Promise<string> }).renderIndexHtml();
|
|
expect(html).toContain('<title>小码<&助手:laptop</title>');
|
|
expect(html).not.toContain('<title>小码<&助手:laptop</title>');
|
|
});
|
|
});
|