mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
Review fixes for "place new, child and web tabs into groups". - Spawned tabs follow their parent only with the new synced setting spawnedTabsFollowParent (App Settings > Appearance > Tabs, off by default). TabLayoutService.sessionCreated() reads it fresh through a childrenFollowParent dep: off, a spawned session lands after its parent and is marked manual in the same write; on, it follows until moved by hand. The row menu shows the placement note and "Follow parent again" only when on. "New session" in a group menu is unaffected. zh-CN label and a Settings-Reference row. - applyLegacySessionRank marks a child manual only when the requested order splits it from its parent's block. A PUT of the current order, or one that moves a family together, leaves every stored placement alone. - The browser never echoes a create or delete through PUT /api/session-order, with or without groups: the layout service already re-projects the order and broadcasts it, and syncSessionOrder adopts it. - The placement note in the row menu is plain text described by the menu, not a focusable menu item. - A tab that first appears inside a collapsed group briefly marks that group's header. - i18n skips .tab-name, so tab names are never translated. - Tests: legacy-order cases for an unchanged order, a family reorder and a split; service cases for the setting and for order PUTs; the scheduled registration test now exercises register/undo; the grouped-create test binds port 0; one route case runs through the real layout service.
255 lines
12 KiB
TypeScript
255 lines
12 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('never translates a tab name, even one that reads like a UI string', async () => {
|
|
const tab = (name: string) =>
|
|
`<div class="session-tab"><span class="tab-info"><span class="tab-name-row">` +
|
|
`<span class="tab-name" data-full-name="${name}">${name}</span></span></span></div>`;
|
|
const dom = makeDom(
|
|
`<div id="sessionTabs">${tab('New session')}${tab('Follows up on bug')}</div><div id="menu"></div>`
|
|
);
|
|
const { window } = dom;
|
|
const api = window.CodemanI18n;
|
|
api.start();
|
|
api.configure({ language: 'zh-CN' });
|
|
const names = () => [...window.document.querySelectorAll('.tab-name')].map((el) => el.textContent);
|
|
expect(names()).toEqual(['New session', 'Follows up on bug']);
|
|
// A tab drawn after the switch stays untranslated too, while the same
|
|
// strings as real UI text still translate.
|
|
window.document.getElementById('sessionTabs')!.insertAdjacentHTML('beforeend', tab('New session'));
|
|
const menu = window.document.getElementById('menu')!;
|
|
menu.innerHTML = '<button>New session</button><div class="tab-rail-action-menu-note">Follows One</div>';
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(names()).toEqual(['New session', 'Follows up on bug', 'New session']);
|
|
expect(menu.querySelector('button')?.textContent).toBe('新建会话');
|
|
expect(menu.querySelector('div')?.textContent).toBe('跟随 One');
|
|
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>');
|
|
});
|
|
});
|