fix(adopt): zh-CN, registry labels, and the outside-sessions block for non-admins

Every string the block shows now has a zh-CN entry, harvested by a test from the
real module. The row's mode comes from the CLI catalog, not a list of ids that
had already missed omp and copilot. The close dialog's line is static, so the
translator can reach it.

A non-admin's poll answers 403, which read as a failed poll: "Could not reach
the server" under a heading they cannot use, and a request every eight seconds.
That now stops the polling and shows no block.

Wide-scan notes are kept apart from local ones, so the next local poll no longer
wipes "remote X: unreachable" while its rows stay on the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 03:13:52 +01:00
1 parent 7de4201955
commit d62fa10c01
5 files changed
+220 -66

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
/**
* @fileoverview The strings of the outside-sessions block in Chinese (zh-CN).
*
* Harvested from the real module rather than listed here, so a new string without a
* dictionary entry fails this test instead of showing up English on one surface. The
* block's row text carries data-i18n-skip (a session name and a command line are
* someone else's), so its location words only translate through the module's own
* foreignT() call, and the DOM translator would never reach them.
*
* Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterAll, describe, expect, it } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
const MODULE = readFileSync(resolve(PUBLIC, 'foreign-sessions.js'), 'utf8');
type Api = { configure(o: Record<string, string>): void; t(source: string): string };
/** Everything the module sends through foreignT, plus the toggle's two literal labels. */
const FROM_MODULE = [...MODULE.matchAll(/foreignT\('([^']+)'\)/g)].map((m) => m[1]);
const LOCATION_WORDS = ['this machine', 'container', 'remote'];
const TOGGLE = ['⟳ all', '⟳ local'];
const ELSEWHERE = [
'Close Tab',
'Detaches only. The tmux session keeps running for whoever started it.',
'Where the tmux session was when it was opened here. It was started outside Codeman and keeps running if this tab closes.',
'Git status is not available for a tmux session opened outside Codeman.',
];
const ALL = [...FROM_MODULE, ...LOCATION_WORDS, ...TOGGLE, ...ELSEWHERE];
const dom = new JSDOM('<!doctype html><html><body></body></html>', { runScripts: 'outside-only', url: 'http://localhost/' });
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
api.configure({ language: 'zh-CN' });
afterAll(() => {
dom.window.close();
});
describe('the outside-sessions block in zh-CN', () => {
it('harvests the module (the check is not vacuous)', () => {
expect(FROM_MODULE).toContain('Opened outside Codeman');
expect(FROM_MODULE).toContain('Go to tab');
expect(FROM_MODULE.length).toBeGreaterThanOrEqual(10);
});
it('wraps every location word, whose row the DOM translator skips', () => {
expect(MODULE).toContain('foreignT(FOREIGN_LOCATION_LABEL[row.location] || row.location)');
for (const word of LOCATION_WORDS) expect(MODULE).toContain(`: '${word}'`);
});
it.each(ALL)('translates %s', (source) => {
const chinese = api.t(source);
expect(chinese).not.toBe(source);
expect(chinese.replace(/\b(Codeman|tmux|Git)\b/g, '').match(/[A-Za-z]+/g) ?? []).toEqual([]);
});
it('adds each key to the dictionary once', () => {
for (const source of new Set(ALL)) {
expect(I18N.split(`'${source}':`).length - 1, source).toBe(1);
}
});
});
+67 -7
View File
@@ -41,7 +41,7 @@ function stubEl(): Record<string, unknown> {
}
/** The module is a classic script that mixes into CodemanApp.prototype. */
function loadApp() {
function loadApp(window: Record<string, unknown> = {}) {
const host = stubEl();
const ctx: Record<string, unknown> = {
console,
@@ -55,6 +55,7 @@ function loadApp() {
setInterval: () => 1,
clearInterval() {},
codemanT: null,
window,
document: {
createElement: () => stubEl(),
getElementById: (id: string) => (id === 'foreignSessions' ? host : null),
@@ -72,9 +73,17 @@ function loadApp() {
adoptForeignSession(id: string, el?: unknown): Promise<void>;
_allForeignRows(): Array<{ id: string }>;
renderAllForeignSessions(): void;
startForeignPolling(): void;
renderForeignSessions(el: unknown): void;
_foreignRow(row: object): { children: Array<{ children: Array<{ textContent: string }> }> };
};
}
/** A response as `_api` hands it over, the server's envelope included. */
function ok(data: unknown) {
return { ok: true, status: 200, json: async () => ({ success: true, data }) };
}
const LOCAL = { id: 'L1', location: 'local', sessionName: 'work', mode: 'claude' };
const DOCKER = { id: 'D1', location: 'docker', sessionName: 'inbox', mode: 'codex' };
@@ -82,11 +91,13 @@ describe('the wide scan is one-shot', () => {
function wired() {
const app = loadApp();
const calls: string[] = [];
app._apiJson = vi.fn(async (path: string) => {
app._api = vi.fn(async (path: string) => {
calls.push(path);
return path.includes('docker=1')
? { sessions: [LOCAL, DOCKER], notes: [], canScanWide: true }
: { sessions: [LOCAL], notes: [], canScanWide: true };
return ok(
path.includes('docker=1')
? { sessions: [LOCAL, DOCKER], notes: ['docker box: not running, no tmux, or engine unreachable'], canScanWide: true }
: { sessions: [LOCAL], notes: ['local: 1 session(s) skipped'], canScanWide: true }
);
});
return { app, calls };
}
@@ -113,6 +124,17 @@ describe('the wide scan is one-shot', () => {
expect(app._allForeignRows().map((r) => r.id)).toEqual(['L1', 'D1']);
});
it('keeps a container or host note across the local polls, and refreshes the local ones', async () => {
// Only a wide scan can re-read a container's note, so a local poll wiping it
// left "unreachable" gone while its stale rows stayed on the list.
const { app } = wired();
app.toggleForeignScanRemote();
await vi.waitFor(() => expect(app._allForeignRows()).toHaveLength(2));
await app.loadForeignSessions();
expect(app._foreignWideNotes).toEqual(['docker box: not running, no tmux, or engine unreachable']);
expect(app._foreignNotes).toEqual(['local: 1 session(s) skipped']);
});
it('discards the wide rows when the toggle goes off', async () => {
// Nothing re-confirms them, so leaving them up would show a container
// session that may be long gone.
@@ -141,7 +163,6 @@ describe('an adoption failure reports what the server said', () => {
const toasts: string[] = [];
app.showToast = (msg: string) => void toasts.push(msg);
app._api = vi.fn(async () => ({ ok: status < 400, status, json: async () => body }));
app._apiJson = vi.fn(async () => ({ sessions: [], notes: [], canScanWide: false }));
app._onSessionCreated = vi.fn();
app.selectSession = vi.fn(async () => {});
return { app, toasts };
@@ -169,7 +190,6 @@ describe('an adoption failure reports what the server said', () => {
const toasts: string[] = [];
app.showToast = (msg: string) => void toasts.push(msg);
app._api = vi.fn(async () => null); // network failure
app._apiJson = vi.fn(async () => ({ sessions: [], notes: [], canScanWide: false }));
await app.adoptForeignSession('cand-1');
expect(toasts[0]).toMatch(/Could not open that session/);
});
@@ -182,3 +202,43 @@ describe('an adoption failure reports what the server said', () => {
expect(app.selectSession).toHaveBeenCalledWith('new-1');
});
});
describe('a non-admin in multi-user mode', () => {
// The list is admin-only, so every poll answers 403. That used to read as a failed
// poll: a "Could not reach the server" block, and a request every eight seconds.
it('stops asking and shows no block at all', async () => {
const app = loadApp();
const calls: string[] = [];
app._api = vi.fn(async (path: string) => {
calls.push(path);
return { ok: false, status: 403, json: async () => ({ success: false, errorCode: 'FORBIDDEN' }) };
});
await app.loadForeignSessions();
const box = stubEl();
app.renderForeignSessions(box);
expect(box.hidden).toBe(true);
expect(box.children).toEqual([]);
await app.loadForeignSessions();
app.startForeignPolling();
expect(calls).toHaveLength(1);
});
it('still reads any other failure as a failed poll', async () => {
const app = loadApp();
app._api = vi.fn(async () => ({ ok: false, status: 500, json: async () => ({ success: false }) }));
await app.loadForeignSessions();
expect(app._foreignError).toBe(true);
expect(app._foreignRefused).toBeFalsy();
});
});
describe('a row names its CLI in the registry\u2019s own word', () => {
it('has no list of CLI ids to fall behind: omp and copilot read like the rest', () => {
const app = loadApp({ __codemanCliCatalog: [{ id: 'copilot', label: 'GitHub Copilot' }] });
const sub = (mode: string) =>
app._foreignRow({ sessionName: 'x', mode, location: 'local' }).children[1].children[1].textContent;
expect(sub('copilot')).toBe('GitHub Copilot \u00b7 this machine');
// Absent from the catalog: the id itself, never a blank row.
expect(sub('newcli')).toBe('newcli \u00b7 this machine');
});
});