mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
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:
1 parent
7de4201955
commit
d62fa10c01
5 files changed
+220
-66
No files matched your search
@@ -10267,11 +10267,11 @@ class CodemanApp {
|
||||
// response re-reports the count, so a pane that starts keeping history
|
||||
// (claude switched to its inline view) goes back to the tail on the next
|
||||
// switch.
|
||||
// A remote (ssh) or Docker session's local pane hosts a nested tmux client in the
|
||||
// alternate screen, so it reports 0 while its byte tail is real scrollback.
|
||||
// A remote (ssh), Docker or adopted session's local pane hosts a nested tmux client in
|
||||
// the alternate screen, so it reports 0 while its byte tail is real scrollback.
|
||||
const shellSession = session?.mode === 'shell';
|
||||
const paneKeepsNoHistory =
|
||||
!session?.remote && !session?.docker && this._paneHistoryLines?.get(sessionId) === 0;
|
||||
!session?.remote && !session?.docker && !session?.adopt && this._paneHistoryLines?.get(sessionId) === 0;
|
||||
const useFullHistory = !shellSession && (paneKeepsNoHistory || !this._fullHistoryLoaded.has(sessionId));
|
||||
if (useFullHistory) this._fullHistoryLoaded.add(sessionId);
|
||||
const fetchStartedAt = performance.now();
|
||||
@@ -10929,7 +10929,7 @@ class CodemanApp {
|
||||
if (keepTitle) keepTitle.textContent = adopted ? 'Close Tab' : 'Remove Tab';
|
||||
if (keepDesc) {
|
||||
keepDesc.textContent = adopted
|
||||
? `Detaches only — "${session.adopt.targetSession}" keeps running for whoever started it`
|
||||
? 'Detaches only. The tmux session keeps running for whoever started it.'
|
||||
: 'Tmux session keeps running in background';
|
||||
}
|
||||
|
||||
|
||||
@@ -38,27 +38,27 @@
|
||||
* (`_onSessionCreated` then `selectSession`) — never by inserting a tab here.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (this.sessions, _onSessionCreated, selectSession, _apiJson)
|
||||
* @dependency api-client.js (_apiJson / _apiPost envelope unwrapping)
|
||||
* @dependency app.js (this.sessions, _onSessionCreated, selectSession)
|
||||
* @dependency api-client.js (_api)
|
||||
* @dependency ralph-panel.js (formatRelativeTime)
|
||||
* @loadorder 12.57 of 16, after home-sessions.js, before entrance-animations.js
|
||||
* @dependency i18n.js (codemanT, optional)
|
||||
* @loadorder 12.565 of 16, after home-sessions.js, before git-status-ui.js
|
||||
*/
|
||||
|
||||
/** Fallback poll cadence; the server sends its own in `pollIntervalMs`. */
|
||||
const FOREIGN_POLL_FALLBACK_MS = 8000;
|
||||
|
||||
/** Mode label shown on a row. Deliberately the same words the tab strip uses. */
|
||||
const FOREIGN_MODE_LABEL = {
|
||||
claude: 'Claude',
|
||||
codex: 'Codex',
|
||||
opencode: 'OpenCode',
|
||||
gemini: 'Gemini',
|
||||
antigravity: 'Antigravity',
|
||||
pi: 'Pi',
|
||||
grok: 'Grok',
|
||||
deepseek: 'DeepSeek',
|
||||
shell: 'Shell',
|
||||
};
|
||||
/** i18n.js's translator, or the English source where it has not loaded. */
|
||||
function foreignT(source) {
|
||||
return typeof codemanT === 'function' ? codemanT(source) : source;
|
||||
}
|
||||
|
||||
/** A row's mode, in the registry's own word for it. Falls back to the id, never a list of ids here. */
|
||||
function foreignModeLabel(mode) {
|
||||
const catalog =
|
||||
typeof window !== 'undefined' && Array.isArray(window.__codemanCliCatalog) ? window.__codemanCliCatalog : [];
|
||||
return catalog.find((entry) => entry.id === mode)?.label || mode;
|
||||
}
|
||||
|
||||
const FOREIGN_LOCATION_LABEL = {
|
||||
local: 'this machine',
|
||||
@@ -77,7 +77,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
* stack another interval on top of the last.
|
||||
*/
|
||||
startForeignPolling() {
|
||||
if (this._foreignPollTimer) return;
|
||||
if (this._foreignPollTimer || this._foreignRefused) return;
|
||||
void this.loadForeignSessions();
|
||||
this._foreignPollTimer = setInterval(() => {
|
||||
// Local only — see @fileoverview. The wide scan never rides the timer.
|
||||
@@ -128,15 +128,24 @@ Object.assign(CodemanApp.prototype, {
|
||||
* ONLY an explicit user action ever passes it — never the interval.
|
||||
*/
|
||||
async loadForeignSessions({ wide = false } = {}) {
|
||||
if (this._foreignRefused) return;
|
||||
const qs = wide ? '?docker=1&remote=1' : '';
|
||||
if (wide) this._foreignWideLoading = true;
|
||||
this.renderAllForeignSessions();
|
||||
let data;
|
||||
try {
|
||||
data = await this._apiJson(`/api/mux/foreign${qs}`);
|
||||
} finally {
|
||||
if (wide) this._foreignWideLoading = false;
|
||||
const res = await this._api(`/api/mux/foreign${qs}`);
|
||||
if (wide) this._foreignWideLoading = false;
|
||||
// ⚠️ A 403 is an answer, not a failed poll. The list is admin-only in multi-user
|
||||
// mode (it shows other people's processes), so a non-admin has nothing to see here
|
||||
// and nothing later on this page changes that: stop asking and show no block,
|
||||
// instead of "Could not reach the server" under a heading they cannot use.
|
||||
if (res?.status === 403) {
|
||||
this._foreignRefused = true;
|
||||
this.stopForeignPolling();
|
||||
this.renderAllForeignSessions();
|
||||
return;
|
||||
}
|
||||
const body = res?.ok ? await this._readJsonBody(res) : null;
|
||||
const data = body?.success === true ? body.data : body?.success === false ? null : body;
|
||||
if (!data) {
|
||||
// A failed poll must not blank a list the user is looking at: keep the
|
||||
// last good result and let the next tick recover.
|
||||
@@ -147,18 +156,21 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._foreignError = false;
|
||||
const rows = Array.isArray(data.sessions) ? data.sessions : [];
|
||||
const notes = Array.isArray(data.notes) ? data.notes : [];
|
||||
// Every note opens with its location (foreign-tmux-discovery.ts). A local one is
|
||||
// re-read on each poll; a container or host one only by the next explicit scan,
|
||||
// so it has to outlive the local polls in between.
|
||||
const isLocalNote = (note) => String(note).startsWith('local:');
|
||||
this._foreignNotes = notes.filter(isLocalNote);
|
||||
if (wide) {
|
||||
// Split by location: the local rows refresh on every tick, the rest only
|
||||
// here, so they have to be stored apart or the next local-only payload
|
||||
// (which cannot contain them) would read as "those sessions are gone".
|
||||
this._foreignSessions = rows.filter((r) => (r.location || 'local') === 'local');
|
||||
this._foreignWideSessions = rows.filter((r) => (r.location || 'local') !== 'local');
|
||||
this._foreignNotes = notes;
|
||||
this._foreignWideNotes = [];
|
||||
this._foreignWideNotes = notes.filter((note) => !isLocalNote(note));
|
||||
this._foreignWideAt = Date.now();
|
||||
} else {
|
||||
this._foreignSessions = rows;
|
||||
this._foreignNotes = notes;
|
||||
}
|
||||
this._foreignCanScanWide = data.canScanWide === true;
|
||||
if (Number.isFinite(data.pollIntervalMs)) this._foreignPollMs = data.pollIntervalMs;
|
||||
@@ -176,16 +188,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
/**
|
||||
* Adopt one candidate.
|
||||
*
|
||||
* The in-flight lock is per BUTTON, not global: two different foreign sessions
|
||||
* can legitimately be adopted back to back. The server holds the real
|
||||
* one-wrapper-per-target guarantee; this only stops a double-click.
|
||||
* The in-flight lock is global, one adoption at a time from this page: it only
|
||||
* stops a double-click. The server holds the real one-wrapper-per-target
|
||||
* guarantee.
|
||||
*/
|
||||
async adoptForeignSession(id, buttonEl) {
|
||||
if (this._foreignAdoptInFlight) return;
|
||||
this._foreignAdoptInFlight = true;
|
||||
if (buttonEl) {
|
||||
buttonEl.disabled = true;
|
||||
buttonEl.textContent = codemanT ? codemanT('Opening…') : 'Opening…';
|
||||
buttonEl.textContent = foreignT('Opening…');
|
||||
}
|
||||
try {
|
||||
const body = { id };
|
||||
@@ -202,9 +214,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const payload = await this._readJsonBody(res);
|
||||
const session = payload && payload.success !== false ? payload.data?.session : null;
|
||||
if (!session) {
|
||||
const fallback = codemanT
|
||||
? codemanT('Could not open that session. Refreshing the list.')
|
||||
: 'Could not open that session. Refreshing the list.';
|
||||
const fallback = foreignT('Could not open that session. Refreshing the list.');
|
||||
this.showToast?.(payload?.error || fallback, 'error');
|
||||
await this.loadForeignSessions();
|
||||
return;
|
||||
@@ -221,7 +231,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._foreignAdoptInFlight = false;
|
||||
if (buttonEl) {
|
||||
buttonEl.disabled = false;
|
||||
buttonEl.textContent = codemanT ? codemanT('Open') : 'Open';
|
||||
buttonEl.textContent = foreignT('Open');
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -258,6 +268,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
renderForeignSessions(container) {
|
||||
const rows = this._allForeignRows();
|
||||
container.innerHTML = '';
|
||||
if (this._foreignRefused) {
|
||||
container.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
// Hidden rather than shown-empty: on a first run there is usually nothing,
|
||||
@@ -279,12 +293,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
empty.className = 'foreign-empty';
|
||||
this._appendForeignNotes(container);
|
||||
empty.textContent = this._foreignError
|
||||
? codemanT
|
||||
? codemanT('Could not reach the server.')
|
||||
: 'Could not reach the server.'
|
||||
: codemanT
|
||||
? codemanT('No sessions found outside Codeman.')
|
||||
: 'No sessions found outside Codeman.';
|
||||
? foreignT('Could not reach the server.')
|
||||
: foreignT('No sessions found outside Codeman.');
|
||||
container.appendChild(empty);
|
||||
}
|
||||
return;
|
||||
@@ -339,7 +349,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const title = document.createElement('h3');
|
||||
title.className = 'foreign-title';
|
||||
title.textContent = codemanT ? codemanT('Opened outside Codeman') : 'Opened outside Codeman';
|
||||
title.textContent = foreignT('Opened outside Codeman');
|
||||
header.appendChild(title);
|
||||
|
||||
if (count) {
|
||||
@@ -355,9 +365,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
scan.className = 'foreign-scan-toggle';
|
||||
scan.dataset.foreignAction = 'toggle-scan';
|
||||
scan.setAttribute('aria-pressed', String(this.foreignScanRemote()));
|
||||
scan.title = codemanT
|
||||
? codemanT('Also scan containers and remote hosts (slower)')
|
||||
: 'Also scan containers and remote hosts (slower)';
|
||||
scan.title = foreignT('Also scan containers and remote hosts (slower)');
|
||||
scan.textContent = this.foreignScanRemote() ? '⟳ all' : '⟳ local';
|
||||
header.appendChild(scan);
|
||||
|
||||
@@ -369,16 +377,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
rescan.className = 'foreign-scan-toggle';
|
||||
rescan.dataset.foreignAction = 'rescan-wide';
|
||||
rescan.disabled = this._foreignWideLoading === true;
|
||||
rescan.title = codemanT
|
||||
? codemanT('Scan containers and remote hosts again')
|
||||
: 'Scan containers and remote hosts again';
|
||||
rescan.title = foreignT('Scan containers and remote hosts again');
|
||||
rescan.textContent = this._foreignWideLoading
|
||||
? codemanT
|
||||
? codemanT('Scanning…')
|
||||
: 'Scanning…'
|
||||
: codemanT
|
||||
? codemanT('Rescan')
|
||||
: 'Rescan';
|
||||
? foreignT('Scanning…')
|
||||
: foreignT('Rescan');
|
||||
header.appendChild(rescan);
|
||||
}
|
||||
|
||||
@@ -406,8 +408,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const line2 = document.createElement('span');
|
||||
line2.className = 'foreign-row-sub';
|
||||
line2.setAttribute('data-i18n-skip', '');
|
||||
const bits = [FOREIGN_MODE_LABEL[row.mode] || row.mode];
|
||||
const where = row.hostLabel || FOREIGN_LOCATION_LABEL[row.location] || row.location;
|
||||
const bits = [foreignModeLabel(row.mode)];
|
||||
const where = row.hostLabel || foreignT(FOREIGN_LOCATION_LABEL[row.location] || row.location);
|
||||
if (where) bits.push(where);
|
||||
if (row.workingDir) bits.push(row.workingDir);
|
||||
line2.textContent = bits.join(' · ');
|
||||
@@ -422,11 +424,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (row.adoptedBy) {
|
||||
action.dataset.foreignAction = 'select';
|
||||
action.dataset.foreignSession = row.adoptedBy;
|
||||
action.textContent = codemanT ? codemanT('Go to tab') : 'Go to tab';
|
||||
action.textContent = foreignT('Go to tab');
|
||||
} else {
|
||||
action.dataset.foreignAction = 'adopt';
|
||||
action.dataset.foreignId = row.id;
|
||||
action.textContent = codemanT ? codemanT('Open') : 'Open';
|
||||
action.textContent = foreignT('Open');
|
||||
}
|
||||
item.appendChild(action);
|
||||
|
||||
|
||||
@@ -1372,6 +1372,30 @@
|
||||
'Remove Tab': '移除标签页',
|
||||
'Remove All Tabs': '移除所有标签页',
|
||||
'Use arrows to reorder. Changes are saved automatically.': '使用方向键重新排序;更改会自动保存。',
|
||||
// Sessions opened outside Codeman (foreign-sessions.js, the close dialog and Session Options).
|
||||
'Opened outside Codeman': '在 Codeman 之外打开',
|
||||
'Also scan containers and remote hosts (slower)': '同时扫描容器和远程主机(较慢)',
|
||||
'Scan containers and remote hosts again': '重新扫描容器和远程主机',
|
||||
'⟳ all': '⟳ 全部',
|
||||
'⟳ local': '⟳ 本机',
|
||||
'Scanning…': '扫描中…',
|
||||
'Rescan': '重新扫描',
|
||||
'Open': '打开',
|
||||
'Opening…': '正在打开…',
|
||||
'Go to tab': '转到标签页',
|
||||
'this machine': '本机',
|
||||
'container': '容器',
|
||||
'remote': '远程',
|
||||
'No sessions found outside Codeman.': '未发现在 Codeman 之外打开的会话。',
|
||||
'Could not reach the server.': '无法连接服务器。',
|
||||
'Could not open that session. Refreshing the list.': '无法打开该会话,正在刷新列表。',
|
||||
'Close Tab': '关闭标签页',
|
||||
'Detaches only. The tmux session keeps running for whoever started it.':
|
||||
'仅断开连接。该 tmux 会话会继续运行,供其启动者使用。',
|
||||
'Where the tmux session was when it was opened here. It was started outside Codeman and keeps running if this tab closes.':
|
||||
'该 tmux 会话在此处打开时所在的位置。它是在 Codeman 之外启动的,关闭此标签页后仍会继续运行。',
|
||||
'Git status is not available for a tmux session opened outside Codeman.':
|
||||
'在 Codeman 之外打开的 tmux 会话无法使用 Git 状态。',
|
||||
});
|
||||
|
||||
const ZH_CN_LOWER = new Map(Object.entries(ZH_CN).map(([key, value]) => [key.toLocaleLowerCase('en'), value]));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user