Merge the final checkup's fixes into the 1.40.0 release

From the read-only final review of the release, adversarially verified, then reviewed again:
- tiles: a file dropped on the grid uploads to that tile's session instead of navigating away; app-driven tile changes no longer move the keyboard into another session; popping out the last tile no longer leaves a frozen view; "Open group as tiles" no longer merges an open split; the Tiles button defaults off on touch tablets (opt-in)
- voice: dictation with the grid open reaches the focused tile
- css: By case stays one scrolling strip on 600-767px tablets, the needs-you pulse animates opacity only, phone welcome chips are 40px
- i18n: zh-CN for the case picker rows, the git status settings, new toasts, tile and spreadsheet texts
- cli registry: codex launch defaults are registry data, not an id branch; the codex footer reads an ultra effort
- build and docs: a dependency preflight runs before the build deletes dist; docs no longer name 1.36.0

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-09 10:19:41 +02:00
45 changed files with 2159 additions and 234 deletions
+174 -51
View File
@@ -33,6 +33,13 @@
* CAN DO, it belongs in `CliCapabilities` instead — and if it needs to run code, in
* `config/cli-registry/profiles.ts` as a named profile.
*
* ⚠️ Each entry also carries how many times its expression occurs in that file, compared
* EXACTLY. The key is only `<file>::<expression>`, so without a count an approved branch
* approved every later copy of the same text in the same file: the codex launch defaults
* added two more `mode === 'codex'` branches to session-routes.ts and passed silently,
* because the legacy-plumbing entry already covered that string. A new copy now fails as
* an unapproved branch would, and a removed one fails as stale until the count drops.
*
* Port: none (pure static analysis).
*/
@@ -67,47 +74,67 @@ const EXEMPT_FILES = new Set(
].map((p) => p.split('/').join(sep))
);
/** One approved branch: how many copies of it the file holds, and why it is not a capability. */
interface Allowance {
count: number;
reason: string;
}
const allow = (count: number, reason: string): Allowance => ({ count, reason });
/**
* Specific surviving branches, each with the reason it is not a capability.
* Specific surviving branches, each with the reason it is not a capability and the exact
* number of times the expression occurs in that file (see the header).
* Keyed `<relative path>::<the matched expression>`.
*/
const ALLOWED_BRANCHES: Record<string, string> = {
const ALLOWED_BRANCHES: Record<string, Allowance> = {
// --- Legacy <Mode>Config plumbing (public wire shape, see the header) ---
"web/routes/session-routes.ts::mode === 'opencode'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'codex'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'gemini'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'antigravity'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'pi'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'grok'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'deepseek'": 'legacy <Mode>Config plumbing',
"web/server.ts::mode === 'opencode'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'codex'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'gemini'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'antigravity'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'pi'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'grok'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'deepseek'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'omp'": 'legacy <Mode>Config plumbing (session recovery)',
"web/routes/session-routes.ts::mode === 'opencode'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'codex'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'gemini'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'antigravity'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'pi'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'grok'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'deepseek'": allow(2, 'legacy <Mode>Config plumbing'),
"web/server.ts::mode === 'opencode'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'codex'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'gemini'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'antigravity'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'pi'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'grok'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'deepseek'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'omp'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
// --- Claude's remote/docker command construction ---
"tmux-manager.ts::mode === 'claude'":
"tmux-manager.ts::mode === 'claude'": allow(
2,
"claude's remote pane command carries per-session permission flags, and its docker form is " +
'`--session-id … || resume`; neither fits a static overlays.command string',
"tmux-manager.ts::mode === 'omp'":
'`--session-id … || resume`; neither fits a static overlays.command string'
),
"tmux-manager.ts::mode === 'omp'": allow(
1,
'remote omp respawn needs the pinned/continue --resume override threaded through ' +
'(resumeSessionId/ompConfig), which the static overlays.remote.command string has no ' +
'room for; the command itself is still rendered through buildSpawnCommandFromRegistry, ' +
'the same mode-agnostic engine local/docker spawns use — only the BRANCH is per-mode',
'(resumeSessionId/ompConfig), which the static overlays.remote.command string has no ' +
'room for; the command itself is still rendered through buildSpawnCommandFromRegistry, ' +
'the same mode-agnostic engine local/docker spawns use — only the BRANCH is per-mode'
),
// --- Per-CLI prose and launch handling not yet generalised ---
"web/session-wait-registry.ts::mode === 'deepseek'":
'an error message explaining why THIS mode in particular will never deliver a stop signal',
"web/routes/approval-routes.ts::mode === 'deepseek'":
'the DeepSeek status bridge is the only non-claude source of approval items',
"cron/cron-service.ts::mode === 'claude'": 'cron launch handling, not yet generalised',
"cron/cron-service.ts::mode === 'shell'": 'cron launch handling, not yet generalised',
"web/routes/session-routes.ts::mode === 'claude'": 'docker case bookkeeping keyed on the claude conversation id',
"cli.ts::mode === 'shell'": 'a CLI-table label, not behaviour',
"web/session-wait-registry.ts::mode === 'deepseek'": allow(
1,
'an error message explaining why THIS mode in particular will never deliver a stop signal'
),
"web/routes/approval-routes.ts::mode === 'deepseek'": allow(
1,
'the DeepSeek status bridge is the only non-claude source of approval items'
),
"cron/cron-service.ts::mode === 'claude'": allow(1, 'cron launch handling, not yet generalised'),
"cron/cron-service.ts::mode === 'shell'": allow(1, 'cron launch handling, not yet generalised'),
"web/routes/session-routes.ts::mode === 'claude'": allow(
2,
'docker case bookkeeping keyed on the claude conversation id'
),
"cli.ts::mode === 'shell'": allow(1, 'a CLI-table label, not behaviour'),
// --- Negated forms surfaced when BRANCH_PATTERN widened past `===` (see its comment) ---
//
@@ -121,43 +148,58 @@ const ALLOWED_BRANCHES: Record<string, string> = {
// there, the shared predicate silently widened both to a mode with no transcript to read.
// CLAUDE.md documents this as deliberate and `test/deepseek-mode.test.ts` pins it, so a
// capability here would be actively wrong.
"web/routes/readmymind-routes.ts::mode !== 'claude'":
'deliberately mode-not-capability; pinned by deepseek-mode.test.ts',
"web/server.ts::mode !== 'claude'":
"web/routes/readmymind-routes.ts::mode !== 'claude'": allow(
1,
'deliberately mode-not-capability; pinned by deepseek-mode.test.ts'
),
"web/server.ts::mode !== 'claude'": allow(
2,
"intent capture reads Claude's own transcript, and the recovered-workspace hook sweep " +
'writes .claude hooks — both are claude questions, not capability ones (see CLAUDE.md)',
'writes .claude hooks — both are claude questions, not capability ones (see CLAUDE.md)'
),
// The TUI is a CLIENT of the server, and these two are about what it can offer for a row:
// resume builds a `claude --resume`, and the mode badge is suppressed for the default mode
// purely so the common case reads clean. The badge one is cosmetic and not a capability at
// all; the resume one would need a "resumable from a claude transcript" field that nothing
// else would read.
"tui/tui-app.ts::mode !== 'claude'": 'TUI resume builds a claude --resume; claude-transcript-only by construction',
"tui/tui-render.ts::mode !== 'claude'": 'cosmetic: suppress the mode badge for the default mode',
"tui/tui-app.ts::mode !== 'claude'": allow(
1,
'TUI resume builds a claude --resume; claude-transcript-only by construction'
),
"tui/tui-render.ts::mode !== 'claude'": allow(1, 'cosmetic: suppress the mode badge for the default mode'),
// Push approve/deny BUTTONS are withheld for dsh because the answer route refuses
// keystrokes for its dialogs (third-party TUI, unmeasured contract) — a button whose
// answer would be refused is worse than none. Arguably wants an "answerable dialogs"
// capability; deliberately not invented here.
"web/routes/hook-event-routes.ts::mode !== 'deepseek'":
'push buttons withheld where the answer route refuses keystrokes',
"web/routes/hook-event-routes.ts::mode !== 'deepseek'": allow(
1,
'push buttons withheld where the answer route refuses keystrokes'
),
// Legacy <Mode>Config plumbing, same category as the `===` entries above.
"web/routes/session-routes.ts::mode !== 'omp'": 'legacy <Mode>Config plumbing (resolveOmpConfigForCreate)',
"web/routes/session-routes.ts::mode !== 'omp'": allow(
2,
'legacy <Mode>Config plumbing (resolveOmpConfigForCreate), and one link of the scaffolded-case hooks ' +
'chain (see the opencode entry below)'
),
// ⚠️ Scaffolded-case hooks. This chain excludes seven CLIs but NOT `deepseek`, while its
// own comment says DeepSeek uses its own system — so a scaffolded deepseek case gets a
// Claude hooks block written into it. That inconsistency is UPSTREAM's and predates this
// change; expressing the chain as a capability would have to pick a side and would
// therefore be a behaviour change. Left exactly as found, and named here so it is visible.
"web/routes/session-routes.ts::mode !== 'opencode'":
"web/routes/session-routes.ts::mode !== 'opencode'": allow(
2,
'scaffolded-case hooks + the COD-91 self-heal skip; the chain omits deepseek upstream, ' +
'so any capability form would change behaviour — see PR discussion',
"web/routes/session-routes.ts::mode !== 'codex'": 'scaffolded-case hooks (see the opencode entry)',
"web/routes/session-routes.ts::mode !== 'gemini'": 'scaffolded-case hooks (see the opencode entry)',
"web/routes/session-routes.ts::mode !== 'antigravity'": 'scaffolded-case hooks (see the opencode entry)',
"web/routes/session-routes.ts::mode !== 'pi'": 'scaffolded-case hooks (see the opencode entry)',
"web/routes/session-routes.ts::mode !== 'grok'": 'scaffolded-case hooks (see the opencode entry)',
'so any capability form would change behaviour — see PR discussion'
),
"web/routes/session-routes.ts::mode !== 'codex'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
"web/routes/session-routes.ts::mode !== 'gemini'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
"web/routes/session-routes.ts::mode !== 'antigravity'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
"web/routes/session-routes.ts::mode !== 'pi'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
"web/routes/session-routes.ts::mode !== 'grok'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
};
/** Every stock CLI id, derived rather than restated so a new entry is covered automatically. */
@@ -246,6 +288,43 @@ function scan(): { findings: Finding[]; filesScanned: number } {
const { findings, filesScanned } = scan();
interface CountMismatch {
key: string;
allowed: number;
found: number;
lines: string[];
}
/**
* Allowlisted keys whose occurrence count differs from the approved one: `grown` holds the
* keys with MORE copies than approved (a new branch riding an old approval), `shrunk` the
* ones with fewer (a stale approval that would let the next copy back in unseen).
* Unallowlisted keys are not this function's business; the offenders check covers them.
*/
function countMismatches(
found: Finding[],
allowed: Record<string, Allowance>
): { grown: CountMismatch[]; shrunk: CountMismatch[] } {
const byKey = new Map<string, Finding[]>();
for (const f of found) byKey.set(f.key, [...(byKey.get(f.key) ?? []), f]);
const grown: CountMismatch[] = [];
const shrunk: CountMismatch[] = [];
for (const [key, allowance] of Object.entries(allowed)) {
const hits = byKey.get(key) ?? [];
const mismatch = {
key,
allowed: allowance.count,
found: hits.length,
lines: hits.map((f) => `${f.file}:${f.line}`),
};
if (hits.length > allowance.count) grown.push(mismatch);
else if (hits.length < allowance.count) shrunk.push(mismatch);
}
return { grown, shrunk };
}
const { grown, shrunk } = countMismatches(findings, ALLOWED_BRANCHES);
describe('no CLI-id branching outside the stock catalog', () => {
it('scans a meaningful number of source files (sanity)', () => {
// If this collapses toward zero the walker or the exemption list drifted and every
@@ -293,12 +372,56 @@ describe('no CLI-id branching outside the stock catalog', () => {
).toEqual([]);
});
it('has no new copy of an allowlisted branch', () => {
// An approval covers the copies that were reviewed, not every later line that happens
// to spell the same expression in the same file.
const detail = grown
.map((m) => ` ${m.key}: ${m.found} found, ${m.allowed} approved\n ${m.lines.join('\n ')}`)
.join('\n');
expect(
grown,
grown.length === 0
? ''
: `Found more copies of an allowlisted CLI-id branch than were approved:\n${detail}\n\n` +
'Express the new copy as a CliCapabilities field (or a named profile) rather than raising ' +
"the count. Raise it only for another branch of the SAME kind, and read this file's header first."
).toEqual([]);
});
it('has no stale allowlist entries', () => {
// An allowlisted branch that no longer exists is a lie about the codebase, and the next
// person to reintroduce that exact branch would sail straight through.
const present = new Set(findings.map((f) => f.key));
const stale = Object.keys(ALLOWED_BRANCHES).filter((key) => !present.has(key));
expect(stale, `ALLOWED_BRANCHES entries no longer present — delete them:\n ${stale.join('\n ')}`).toEqual([]);
// person to reintroduce that exact branch would sail straight through. The same holds
// for an approved count above what the file still has.
const stale = shrunk.map((m) => `${m.key}: ${m.found} found, ${m.allowed} approved`);
expect(
stale,
`ALLOWED_BRANCHES entries no longer (fully) present; delete them or lower the count:\n ${stale.join('\n ')}`
).toEqual([]);
});
it('counts copies per key, so one extra copy of an approved branch fails (anti-vacuity)', () => {
const at = (line: number): Finding => ({
file: 'web/example.ts',
expression: "mode === 'codex'",
line,
key: "web/example.ts::mode === 'codex'",
});
const approved = { "web/example.ts::mode === 'codex'": allow(1, 'synthetic') };
expect(countMismatches([at(10)], approved)).toEqual({ grown: [], shrunk: [] });
const extra = countMismatches([at(10), at(42)], approved);
expect(extra.grown).toEqual([
{
key: "web/example.ts::mode === 'codex'",
allowed: 1,
found: 2,
lines: ['web/example.ts:10', 'web/example.ts:42'],
},
]);
expect(countMismatches([], approved).shrunk.map((m) => m.key)).toEqual(["web/example.ts::mode === 'codex'"]);
// Every live entry carries a positive whole count, or the comparison means nothing.
for (const [key, { count }] of Object.entries(ALLOWED_BRANCHES)) {
expect(Number.isInteger(count) && count > 0, key).toBe(true);
}
});
});
+51
View File
@@ -291,6 +291,57 @@ describe('cross-field integrity', () => {
});
});
describe('capabilities.launchDefaults', () => {
/** Codex with its shipped launch defaults replaced by `launchDefaults` (or with them unchanged). */
function codexWith(mutate: (entry: Record<string, unknown>) => void): boolean {
const entry = baseEntry('codex');
mutate(entry);
return CliEntrySchema.safeParse(entry).success;
}
const setDefaults = (value: unknown) => (e: Record<string, unknown>) => {
(e.capabilities as Record<string, unknown>).launchDefaults = value;
};
it('ships on codex alone, keyed by launch param', () => {
const declaring = STOCK_CLIS.filter((e) => e.capabilities.launchDefaults !== undefined).map((e) => e.id);
expect(declaring).toEqual(['codex']);
expect(codexWith(() => {})).toBe(true);
});
it('rejects a param the entry never declared', () => {
// Filled into the config object and then read by nothing: a silent no-op, like a
// privilegedParams clamp naming the wrong param.
expect(codexWith(setDefaults({ effort: 'codexReasoningEffort' }))).toBe(false);
});
it('rejects a settings key outside the closed list', () => {
// An override must not be able to pour an arbitrary setting onto a command line.
expect(codexWith(setDefaults({ model: 'claudeModel' }))).toBe(false);
expect(codexWith(setDefaults({ model: 'codexmodel' }))).toBe(false);
});
it('rejects an empty map', () => {
expect(codexWith(setDefaults({}))).toBe(false);
});
it('refuses an entry with no legacyConfigField to fill', () => {
// Without one the params are read off the request body itself, where `model` is
// claude's per-session field, not this CLI's.
expect(
codexWith((e) => {
delete (e.launch as Record<string, unknown>).legacyConfigField;
})
).toBe(false);
// The same entry without launch defaults is fine: the refusal is about the pair.
expect(
codexWith((e) => {
delete (e.launch as Record<string, unknown>).legacyConfigField;
delete (e.capabilities as Record<string, unknown>).launchDefaults;
})
).toBe(true);
});
});
describe('the env allowlist cannot be widened by config', () => {
it('requires a prefix to end with an underscore', () => {
expectRejected((e) => {
+71
View File
@@ -14,6 +14,9 @@
* - The parts only the new styles draw (stat rings, plan rings, meters, tile words)
* are rendered with sane values and hidden by default in CSS, which is what
* keeps 'classic' looking exactly as before.
* - The connection tile's value word reads in Chinese through i18n.js's real
* t(), from scoped keys (a bare 'retry' or 'live' key would also translate
* other surfaces), and follows a language switch.
*
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only').
*
@@ -22,6 +25,7 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import postcss from 'postcss';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
@@ -195,6 +199,73 @@ describe('the parts the new styles draw', () => {
expect(document.getElementById('connectionTileValue')!.className).toBe('connection-tile-value connected');
});
it('writes the value word in the UI language, from scoped keys, and follows a language switch', () => {
const translator = (language: string) => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
runScripts: 'outside-only',
url: 'http://localhost/',
});
vm.runInContext(read('i18n.js'), dom.getInternalVMContext(), { filename: 'i18n.js' });
const api = (dom.window as unknown as { CodemanI18n: { t(s: string): string; configure(o: object): void } })
.CodemanI18n;
api.configure({ language });
return { t: api.t, close: () => dom.window.close() };
};
const zh = translator('zh-CN');
const en = translator('en');
try {
window.codemanT = zh.t;
window.CodemanI18n = { language: 'zh-CN' };
const app = makeApp();
app._computeConnectionDescriptor = () => ({
display: 'flex',
dotClass: 'connection-dot connected',
text: 'WS',
title: 'Terminal connected over WebSocket',
});
app._updateConnectionIndicator();
expect(document.getElementById('connectionTileLabel')!.textContent).toBe('WS');
expect(document.getElementById('connectionTileValue')!.textContent).toBe('已连接');
// A switch back to English repaints it with nothing else happening: the
// value span is data-i18n-skip, so only applyLocalization (settings-ui.js,
// which every language change runs) can, and the same descriptor is not
// skipped as unchanged. No indicator update by hand here.
window.CodemanI18n = {
language: 'zh-CN',
configure({ language }: { language: string }) {
this.language = language;
window.codemanT = language === 'zh-CN' ? zh.t : en.t;
return { language };
},
};
app.loadAppSettingsFromStorage = () => ({ language: 'en' });
app.applyLocalization();
expect(window.CodemanI18n.language).toBe('en');
expect(document.getElementById('connectionTileValue')!.textContent).toBe('live');
// Every value word has its own Chinese, and none is a bare key.
window.codemanT = zh.t;
const values = [
'connection-dot connected',
'connection-dot fallback',
'connection-dot offline',
'connection-dot draining',
'connection-dot reconnecting',
].map((dotClass) => app._connectionTileWords({ dotClass, text: '' }).value);
expect(values).toEqual(['live', 'fallback', 'offline', 'queued', 'retry']);
const shown = values.map((value: string) => app._connectionTileValueText(value));
expect(shown.filter((text: string) => /[A-Za-z]/.test(text))).toEqual([]);
expect(values.filter((value: string) => zh.t(value) !== value)).toEqual([]);
// The span the observer must leave alone (its text is set here, already translated).
expect(INDEX).toMatch(/id="connectionTileValue" data-i18n-skip>/);
} finally {
delete window.codemanT;
delete window.CodemanI18n;
zh.close();
en.close();
}
});
it('fills the CPU and MEM rings from the stats poll, clamped, red past 80%', () => {
const app = makeApp();
const ring = (id: string) => {
+41
View File
@@ -7,6 +7,10 @@
* session name in the accessible name passed through untranslated (a name
* that is itself a dictionary word included), and read unchanged in English.
*
* The toolbar's case picker rows (which replaced the translated "+" and gear
* buttons) and the host-window and dictation toasts are read from their source,
* so a renamed label without an entry fails here.
*
* Port: N/A.
*/
import { readFileSync } from 'node:fs';
@@ -183,3 +187,40 @@ describe('the Help modal and the shortcut overlay in zh-CN', () => {
expect(overlay).not.toMatch(/<kbd>/);
});
});
describe('the case picker rows and the host-window and dictation toasts in zh-CN', () => {
const SESSION_UI = read('session-ui.js');
const actions = SESSION_UI.slice(
SESSION_UI.indexOf('const CASE_PICKER_ACTIONS = ['),
SESSION_UI.indexOf('];', SESSION_UI.indexOf('const CASE_PICKER_ACTIONS = ['))
);
const pickerLabels = [...actions.matchAll(/label: '([^']+)'/g)].map((m) => m[1]);
const toasts = ['app.js', 'panels-ui.js', 'webview-tabs.js', 'voice-input.js'].flatMap((file) =>
[
...read(file).matchAll(
/showToast\??\.?\(\s*'(Could not open a new window for this \w+|That session has closed; dictation not sent)'/g
),
].map((m) => m[1])
);
it('finds the strings it checks (the check is not vacuous)', () => {
expect(pickerLabels).toEqual(['New or link a case\u2026', 'Case settings\u2026']);
expect(SESSION_UI).toContain('<div class="case-combobox-empty">No cases match</div>');
expect(toasts.sort()).toEqual([
'Could not open a new window for this dashboard',
'Could not open a new window for this preview',
'Could not open a new window for this session',
'That session has closed; dictation not sent',
]);
});
it('each one reads in Chinese with no English left, and unchanged in English', () => {
const bad = [...pickerLabels, 'No cases match', ...toasts].filter((s) => {
const text = zh.api.t(s);
return text === s || leftover(text).length > 0 || en.api.t(s) !== s;
});
expect(bad).toEqual([]);
// The rows reuse the wording of the buttons they replaced.
expect(zh.api.t('Case settings\u2026')).toBe(`${zh.api.t('Case settings')}\u2026`);
});
});
+4
View File
@@ -375,6 +375,10 @@ export function resetGridHarness() {
// The CLI catalog the server injects (labels for the harness logos); a test sets its own.
delete windowStub.__codemanCliCatalog;
section.children = [];
// The grid binds its file-drop guard on the section once per app; an earlier
// test's app must not still be listening there.
section.listeners = {};
section.captureFlags = {};
main.className = 'main';
// A split a test left open moved .terminal-wrap into its container, with
// Pane A's header strip in it.
@@ -7,18 +7,26 @@ import { createRouteTestHarness, type RouteTestHarness } from './_route-test-uti
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
import { CASES_DIR, SETTINGS_PATH } from '../../src/web/route-helpers.js';
import { resolveCodexLaunchDefaults } from '../../src/web/codex-launch-defaults.js';
import { applyLaunchDefaults } from '../../src/web/launch-defaults.js';
import { buildCodexCommand } from '../../src/tmux-manager.js';
import { Session } from '../../src/session.js';
import { safeRmHomeTree } from '../mocks/index.js';
import { getDataDir } from '../../src/config/instance.js';
import { SettingsUpdateSchema } from '../../src/web/schemas.js';
import { getCli } from '../../src/config/cli-registry/registry.js';
import { STOCK_CLIS } from '../../src/config/cli-registry/stock.js';
import type { CodexConfig } from '../../src/types.js';
vi.mock('../../src/utils/cli-launcher.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/utils/cli-launcher.js')>();
return { ...actual, resolveCliLaunchError: vi.fn().mockResolvedValue(null) };
});
/** The codexConfig a launch gets from the synced defaults alone (the caller sent `config`). */
async function resolveCodexDefaults(config: CodexConfig | undefined, customEndpoint = false) {
return (await applyLaunchDefaults('codex', { codexConfig: config }, customEndpoint)).codexConfig;
}
describe('Codex launch defaults', () => {
let harness: RouteTestHarness;
const workingDir = join(homedir(), 'codex-default-test');
@@ -76,14 +84,45 @@ describe('Codex launch defaults', () => {
{ codexModel: 'bad;command', codexReasoningEffort: 'invalid' },
]) {
await writeFile(SETTINGS_PATH, JSON.stringify(settings));
expect(await resolveCodexLaunchDefaults(undefined)).toBeUndefined();
expect(buildCodexCommand(await resolveCodexLaunchDefaults(undefined))).toBe('codex');
expect(await resolveCodexDefaults(undefined)).toBeUndefined();
expect(buildCodexCommand(await resolveCodexDefaults(undefined))).toBe('codex');
}
});
it('keeps defaults out of custom endpoint launches', async () => {
const config = { model: 'local-model', animations: false };
expect(await resolveCodexLaunchDefaults(config, true)).toBe(config);
expect(await resolveCodexDefaults(config, true)).toBe(config);
});
it('is driven by the registry: codex declares the defaults, and a CLI without them is untouched', async () => {
// The routes call applyLaunchDefaults for every mode and never ask which CLI it is, so
// what codex gets is exactly what its entry declares.
expect(getCli('codex')!.capabilities.launchDefaults).toEqual({
model: 'codexModel',
reasoningEffort: 'codexReasoningEffort',
});
for (const entry of STOCK_CLIS) {
if ((entry.id as string) === 'codex') continue;
expect(entry.capabilities.launchDefaults, entry.id).toBeUndefined();
const configs = { codexConfig: undefined, geminiConfig: { model: 'g' }, piConfig: undefined };
expect(await applyLaunchDefaults(entry.id, configs), entry.id).toBe(configs);
}
// Only the entry's own config object is filled; the rest of the bag passes through.
const bag = { codexConfig: { animations: false }, geminiConfig: { model: 'g' }, name: 'n' };
const filled = await applyLaunchDefaults('codex', bag);
expect(filled).toEqual({
codexConfig: { animations: false, model: 'gpt-6.1', reasoningEffort: 'high' },
geminiConfig: { model: 'g' },
name: 'n',
});
expect(filled.geminiConfig).toBe(bag.geminiConfig);
expect(bag.codexConfig).toEqual({ animations: false });
// A field the caller sent is never overwritten, one at a time.
expect(await resolveCodexDefaults({ model: 'mine' })).toEqual({ model: 'mine', reasoningEffort: 'high' });
expect(await resolveCodexDefaults({ reasoningEffort: 'low' })).toEqual({
model: 'gpt-6.1',
reasoningEffort: 'low',
});
});
it('does not record unused defaults for a Docker quick-start', async () => {
@@ -150,7 +189,7 @@ describe('Codex launch defaults', () => {
expect((await put({ codexReasoningEffort: 'bogus' })).statusCode).toBe(400);
expect((await put({ codexModel: 'bad;command' })).statusCode).toBe(400);
expect((await put({ codexModel: '', codexReasoningEffort: '' })).statusCode).toBe(200);
expect(await resolveCodexLaunchDefaults(undefined)).toBeUndefined();
expect(await resolveCodexDefaults(undefined)).toBeUndefined();
} finally {
await system.app.close();
}
+24
View File
@@ -6,6 +6,7 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import postcss, { type AtRule } from 'postcss';
import { describe, expect, it } from 'vitest';
function loadRunModeHarness() {
@@ -638,6 +639,29 @@ describe('Codex quick start settings', () => {
expect(html.indexOf('id="welcomeQr"')).toBeGreaterThan(tunnel);
});
it('never gives a phone shorter launcher chips than a tablet', () => {
// styles.css sizes chips for touch screens under (pointer: coarse);
// mobile.css's phone block loads later at equal specificity and wins on
// every phone, so a lower height there shrank the phone's chips below the
// tablet's (36px against 40px).
const chipHeights = (file: string, media: string) => {
const heights: number[] = [];
const css = readFileSync(resolve(import.meta.dirname, `../src/web/public/${file}`), 'utf8');
postcss.parse(css).walkRules('.welcome-chip', (rule) => {
if (rule.parent?.type !== 'atrule' || (rule.parent as AtRule).params !== media) return;
rule.walkDecls('height', (d) => {
heights.push(parseFloat(d.value));
});
});
return heights;
};
const touch = chipHeights('styles.css', '(pointer: coarse)');
const phone = chipHeights('mobile.css', '(max-width: 599px)');
expect(touch).toEqual([40]);
expect(phone.length).toBeGreaterThan(0);
for (const h of phone) expect(h).toBeGreaterThanOrEqual(Math.max(...touch));
});
it('falls back to the first ENABLED agent when the chosen run mode is disabled, never a hardcoded claude', () => {
const catalog = CATALOG.map((cli) =>
cli.id === 'claude' || cli.id === 'codex' ? { ...cli, enabled: false } : cli
+15
View File
@@ -25,6 +25,7 @@ import {
sanitizeModelName,
} from '../src/session-display-model.js';
import { IDLE_SILENCE_MS } from '../src/session-activity.js';
import { CODEX_REASONING_EFFORTS } from '../src/types/session.js';
const detectOf = (mode: string) => getCli(mode)!.capabilities.modelDetect!;
const DSH = compileVersionRegex(detectOf('deepseek').screenLine)!;
@@ -223,6 +224,20 @@ describe('readScreenModel', () => {
expect(readScreenModel(codexPane(null, [], ' ← for agents · ? for shortcuts'), CODEX, CODEX_ROWS)).toBeUndefined();
});
it("reads codex's model at every reasoning effort Codeman can launch it with, ultra included", () => {
// `ultra` is offered by the codexReasoningEffort App Setting and codex's own /model
// picker; a hand-kept effort list in the pattern once left it out, so an ultra
// session's header never named its model. The footer prints the lowercase level.
const ultra = codexPane(' GPT-6-Astra ultra · ~/codeman-cases/testcase', [], ' ? for shortcuts');
expect(readScreenModel(ultra, CODEX, CODEX_ROWS)).toBe('GPT-6-Astra');
for (const effort of [...CODEX_REASONING_EFFORTS, 'default']) {
const atRest = codexPane(` GPT-6-Astra ${effort} · ~/codeman-cases/testcase`, [], ' ? for shortcuts');
expect(readScreenModel(atRest, CODEX, CODEX_ROWS), effort).toBe('GPT-6-Astra');
const typing = codexPane(` GPT-6-Astra ${effort} · ~/codeman-cases/testcase`);
expect(readScreenModel(typing, CODEX, CODEX_ROWS), effort).toBe('GPT-6-Astra');
}
});
it('never takes a transcript line shaped like the footer', () => {
// The agent printed a line exactly like each CLI's footer, and the real footer is
// hidden (a dsh status bar switched off; a codex popup over its last row). The
+74 -2
View File
@@ -7,8 +7,11 @@
* that shape plus the pinned versions and the dev/prod vendoring steps.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { DOCUMENT_ATTACHMENT_EXTENSIONS, isSupportedAttachmentExtension } from '../src/attachment-registry.js';
@@ -94,3 +97,72 @@ describe('spreadsheet preview assets', () => {
expect(read('src/cli.ts')).toContain("DOCUMENT_ATTACHMENT_EXTENSIONS.join(', ')");
});
});
/**
* The build deletes dist/web/public and only then copies the vendor bundles out of
* node_modules. A tree whose node_modules predate exceljs/fflate (a deploy that pulled
* but never ran `npm install`) failed there, with the live assets already gone. The
* preflight at the top of build.mjs resolves them before anything is touched.
*/
describe('build preflight for the spreadsheet vendor packages', () => {
const preflightModules = (build: string): string[] => {
const list = /const BUILD_TIME_MODULES = \[([^\]]*)\]/.exec(build)?.[1] ?? '';
return [...list.matchAll(/'([^']+)'/g)].map((m) => m[1]);
};
it('covers every package prepare-spreadsheet-assets.mjs resolves, and runs before tsc and the clean', () => {
const build = read('scripts/build.mjs');
const preflight = preflightModules(build);
const resolvedByPrepare = [
...read('scripts/prepare-spreadsheet-assets.mjs').matchAll(/require\.resolve\('([^']+)'\)/g),
].map((m) => m[1]);
// Parse sanity; the loop below is the actual coverage check.
expect(resolvedByPrepare).toContain('exceljs/dist/exceljs.min.js');
expect(resolvedByPrepare).toContain('fflate');
for (const specifier of resolvedByPrepare) expect(preflight, specifier).toContain(specifier);
const bail = build.indexOf('[build] run `npm install` first');
expect(bail).toBeGreaterThan(-1);
expect(bail).toBeLessThan(build.indexOf("run('tsc', 'tsc')"));
expect(bail).toBeLessThan(build.indexOf("'rm -rf dist/web/public'"));
});
it('resolves every preflight package in this installed tree', () => {
const requireFromBuild = createRequire(resolve(root, 'scripts/build.mjs'));
const preflight = preflightModules(read('scripts/build.mjs'));
expect(preflight.length).toBeGreaterThan(0);
for (const specifier of preflight) expect(() => requireFromBuild.resolve(specifier), specifier).not.toThrow();
});
it('exits with an npm install hint, and touches nothing, where the packages do not resolve', () => {
// A copy of build.mjs outside the repo: nothing resolves from there, and its ROOT
// (derived from its own location) is the temp dir, so a missing preflight could
// only ever act on that throwaway tree.
const tree = mkdtempSync(join(tmpdir(), 'codeman-build-preflight-'));
try {
mkdirSync(join(tree, 'scripts'));
const copy = join(tree, 'scripts', 'build.mjs');
copyFileSync(resolve(root, 'scripts/build.mjs'), copy);
const requireFromCopy = createRequire(copy);
const unresolvable = ['exceljs/dist/exceljs.min.js', 'fflate'].filter((specifier) => {
try {
requireFromCopy.resolve(specifier);
return false;
} catch {
return true;
}
});
// Precondition: this host has no stray node_modules above the temp dir.
expect(unresolvable.length).toBeGreaterThan(0);
const result = spawnSync(process.execPath, [copy], { cwd: tree, encoding: 'utf8', timeout: 20_000 });
expect(result.status).toBe(1);
expect(result.stderr).toContain(`[build] missing build dependency: ${unresolvable.join(', ')}`);
expect(result.stderr).toContain('run `npm install` first');
expect(result.stdout).not.toContain('[build] tsc');
expect(existsSync(join(tree, 'dist'))).toBe(false);
} finally {
rmSync(tree, { recursive: true, force: true });
}
});
});
+183
View File
@@ -5,6 +5,12 @@
* The renderer runs against a standalone JSDOM window (like i18n-branding.test.ts)
* rather than the `jsdom` vitest environment. Workbook strings are asserted to
* land as text, never markup.
*
* What the user reads is the renderer's, not the worker's: each worker error
* code is one sentence (the raw message goes to the console), feature ids are
* words, and every one of those, the HTTP status line and the notice bar's
* items read in Chinese through i18n.js's real t(); a number format's code is
* workbook text and passes through as is.
*/
import { readFileSync } from 'node:fs';
@@ -14,6 +20,12 @@ import { JSDOM } from 'jsdom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/spreadsheet-preview.js'), 'utf8');
const coreSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/spreadsheet-xlsx-core.js'), 'utf8');
const workerSource = readFileSync(
resolve(import.meta.dirname, '../src/web/public/spreadsheet-preview-worker.js'),
'utf8'
);
const i18nSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/i18n.js'), 'utf8');
const panelsSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/panels-ui.js'), 'utf8');
// A real origin: the renderer resolves its fetch URL against `location.href`.
@@ -392,6 +404,177 @@ describe('spreadsheet preview renderer', () => {
});
});
describe('spreadsheet preview text the user reads', () => {
type Translate = (s: string) => string;
const translator = (language: string): Translate => {
const i18nDom = new JSDOM('<!doctype html><html><body></body></html>', {
runScripts: 'outside-only',
url: 'http://localhost/',
});
vm.runInContext(i18nSource, i18nDom.getInternalVMContext(), { filename: 'i18n.js' });
const api = (i18nDom.window as any).CodemanI18n as { t: Translate; configure(o: object): void };
api.configure({ language });
return api.t;
};
const zh = translator('zh-CN');
const en = translator('en');
/** What may stay Latin in a translation: file formats, the URL acronym, digits. */
const leftover = (text: string) => text.replace(/\b(xlsx|xls|ZIP64|URL)\b/g, '').match(/[A-Za-z]+/g) ?? [];
beforeEach(() => {
WorkerMock.instances = [];
document.body.innerHTML = '<div id="preview"></div>';
delete (window as any).codemanT;
window.requestAnimationFrame = (callback: FrameRequestCallback) =>
setTimeout(() => callback(0), 0) as unknown as number;
window.cancelAnimationFrame = (id: number) => clearTimeout(id);
});
async function openLoaded() {
document.body.innerHTML = '<div id="preview"></div>';
const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }));
loadRenderer(fetchMock).open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 });
const worker = WorkerMock.instances.at(-1)!;
worker.emit({ type: 'ready' });
await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalled());
return worker;
}
const shown = () => document.querySelector('.spreadsheet-preview-message')?.textContent ?? '';
it('shows one sentence per worker error code, never the raw message, and each reads in Chinese', async () => {
const codes = [
...new Set(
[...`${coreSource}\n${workerSource}`.matchAll(/(?:\bfail|XlsxPreviewError)\('([a-z0-9-]+)'/g)].map((m) => m[1])
),
];
// Not vacuous: the refusals the core and the worker raise.
expect(codes).toEqual(expect.arrayContaining(['encrypted', 'zip64', 'malformed', 'cell-limit', 'style-limit']));
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const texts = new Map<string, string>();
for (const code of [...codes, 'parse-failed']) {
const worker = await openLoaded();
worker.emit({ type: 'error', code, message: `raw ${code} detail` });
texts.set(code, shown());
}
expect([...texts.values()].filter((text) => /raw .* detail/.test(text))).toEqual([]);
// Every code the core and worker raise has its own sentence; only an
// unlisted one (ExcelJS's own failures) shows the generic failure.
expect(codes.filter((code) => texts.get(code) === 'Spreadsheet preview failed')).toEqual([]);
expect(texts.get('parse-failed')).toBe('Spreadsheet preview failed');
expect(texts.get('encrypted')).toBe(
'This workbook is password-protected or in the old .xls format, so it cannot be previewed.'
);
// The developer detail is kept, in the console.
expect(warn).toHaveBeenCalledWith(expect.stringContaining('raw encrypted detail'));
const bad = [...new Set(texts.values())].filter((text) => {
const t = zh(text);
return t === text || leftover(t).length > 0 || en(text) !== text;
});
expect(bad).toEqual([]);
} finally {
warn.mockRestore();
}
});
it('words the notice bar: feature ids as words, each item translated, a format code as it is', async () => {
const notice = async () => {
const worker = await openLoaded();
worker.emit({ ...metadata(), warnings: ['pivotTables', 'externalLinks', 'charts', 'drawings', 'macros'] });
const request = worker.postMessage.mock.calls.at(-1)?.[0];
worker.emit({
type: 'tile',
requestId: request.requestId,
sheetId: '1',
cells: [],
warnings: [
'View truncated to the first 2500 cells',
'Formula has no cached result',
'Unsupported cell value',
'Unsupported number format: {name}"x"',
'3 unsupported number formats',
],
});
return document.querySelector('.spreadsheet-preview-notice')!.textContent!;
};
const english = await notice();
expect(english).toBe(
'Some workbook features are not shown: pivot tables, external links, charts, drawings, macros, ' +
'View truncated to the first 2500 cells, Formula has no cached result, Unsupported cell value, ' +
'Unsupported number format: {name}"x", 3 unsupported number formats'
);
// The real English translator reads the feature words as plain words too,
// never as their scoped keys.
(window as any).codemanT = en;
expect(await notice()).toBe(english);
(window as any).codemanT = zh;
const chinese = await notice();
// The format code is workbook text: kept as is ({name} is not a placeholder here).
expect(chinese).toContain('不支持的数字格式: {name}"x"');
expect(leftover(chinese.replace('{name}"x"', ''))).toEqual([]);
expect(chinese.startsWith(`${zh('Some workbook features are not shown')}: 数据透视表, 外部链接, 图表`)).toBe(true);
// Through scoped keys only: the translator runs over the Files panel tree and
// the case picker too, where a bare 'charts' or 'macros' key would rename a
// Helm chart's charts/ or a dbt project's macros/ folder, or a case of that name.
const words = ['charts', 'drawings', 'pivot tables', 'external links', 'macros'];
expect(words.filter((word) => zh(word) !== word || zh(word.toUpperCase()) !== word.toUpperCase())).toEqual([]);
});
it('keeps the notice bar away from the page translator, so a format code is never rewritten', async () => {
// The real i18n.js in the renderer's own window. configure() walks the whole
// body with t(), which fills `{name}` and rebrands "Codeman" in every text
// node it reaches; the bar is written already translated, item by item, and
// ends with workbook text.
const { confirm, alert } = window;
const context = vm.createContext({
window,
document,
Node: window.Node,
NodeFilter: window.NodeFilter,
MutationObserver: window.MutationObserver,
console,
});
vm.runInContext(i18nSource, context, { filename: 'i18n.js' });
try {
const i18n = (window as any).CodemanI18n as { configure(o: object): void };
i18n.configure({ language: 'en', displayName: 'Acme' });
const worker = await openLoaded();
worker.emit({ ...metadata(), warnings: ['charts'] });
const request = worker.postMessage.mock.calls.at(-1)?.[0];
worker.emit({
type: 'tile',
requestId: request.requestId,
sheetId: '1',
cells: [],
warnings: ['Unsupported number format: {name}"Codeman"0'],
});
const bar = document.querySelector('.spreadsheet-preview-notice')!;
const written = 'Some workbook features are not shown: charts, Unsupported number format: {name}"Codeman"0';
expect(bar.textContent).toBe(written);
// A language or display-name change re-walks the page.
i18n.configure({ language: 'en', displayName: 'Acme' });
expect(bar.textContent).toBe(written);
expect(bar.hasAttribute('data-i18n-skip')).toBe(true);
} finally {
delete (window as any).CodemanI18n;
delete (window as any).codemanT;
window.confirm = confirm;
window.alert = alert;
}
});
it('the HTTP status line reads in Chinese with its status', async () => {
const fetchMock = vi.fn(async () => ({ ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) }));
loadRenderer(fetchMock).open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 });
WorkerMock.instances.at(-1)!.emit({ type: 'ready' });
await vi.waitFor(() => expect(shown()).toBe('Spreadsheet preview failed (404)'));
expect(zh(shown())).toBe('电子表格预览失败(404)');
expect(en(shown())).toBe(shown());
expect(zh('Spreadsheet preview must use a same-origin URL')).toBe('电子表格预览必须使用同源 URL');
});
});
/**
* The overlay wiring in panels-ui.js: which route the renderer is pointed at,
* and that the worker/fetch lifecycle is torn down on close and on re-open.
+89
View File
@@ -24,6 +24,7 @@ import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import postcss, { type AtRule, type Rule } from 'postcss';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const PUBLIC = join(process.cwd(), 'src/web/public');
@@ -350,4 +351,92 @@ describe('tab layouts by case and ledger (static)', () => {
);
expect(mobileCss).toMatch(/:where\(\.header\) \.tab-cluster-label \{\s*display: none;/);
});
// The width range a mobile.css rule applies in: the file's own link media
// (max-width: 1023px) narrowed by every @media around the rule. `other` marks
// a rule that also needs a non-width feature, which therefore cannot be
// counted on to apply.
const mobileRuleWidths = (rule: Rule) => {
let min = 0;
let max = 1023;
let other = false;
for (let node = rule.parent; node && node.type !== 'root'; node = node.parent) {
if (node.type !== 'atrule') continue;
const at = node as AtRule;
if (at.name !== 'media') {
other = true;
continue;
}
for (const part of at.params.split(/\s+and\s+/)) {
const m = part.trim().match(/^\((min|max)-width:\s*(\d+)px\)$/);
if (!m) other = true;
else if (m[1] === 'min') min = Math.max(min, Number(m[2]));
else max = Math.min(max, Number(m[2]));
}
}
return { min, max, other };
};
const mobileClusterRules = () => {
const rules: Array<{
selector: string;
decls: Record<string, string>;
widths: ReturnType<typeof mobileRuleWidths>;
}> = [];
postcss.parse(mobileCss).walkRules((rule) => {
if (!rule.selector.includes('tab-cluster') && !rule.selector.includes('tab-name-case')) return;
const decls: Record<string, string> = {};
rule.walkDecls((d) => {
decls[d.prop] = d.value;
});
rules.push({ selector: rule.selector, decls, widths: mobileRuleWidths(rule) });
});
return rules;
};
it('dissolves the boxes on tablet widths too, where the strip is one scrolling row (600 to 767px)', () => {
// getDeviceType() says 'tablet' from 600 to 767px, and updateTabOverflowMode()
// never wraps the strip there. A box allowed to shrink in that one-row strip
// squeezed and wrapped its tabs inside itself, and every tab past a box's
// first line was clipped under the 48px fixed header, unreachable.
const rules = mobileClusterRules();
const box = '.session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster';
for (const width of [360, 599, 600, 700, 744, 767]) {
const applies = (r: (typeof rules)[number]) => !r.widths.other && r.widths.min <= width && width <= r.widths.max;
expect(
rules.some((r) => applies(r) && r.selector === `:where(.header) ${box}` && r.decls.display === 'contents'),
`box dissolved at ${width}px`
).toBe(true);
expect(
rules.some(
(r) => applies(r) && r.selector === ':where(.header) .tab-cluster-label' && r.decls.display === 'none'
),
`label hidden at ${width}px`
).toBe(true);
// With the box, its label and its case-colour border gone, the case part
// of a generated name is the only cue left: the w<n> counter is per case,
// so w1-alpha and w1-beta would otherwise both read "w1". It must outrank
// styles.css's `.session-tabs.tabs-clusters .tab-name-case { display: none }`.
expect(
rules.some(
(r) =>
applies(r) &&
r.selector === ':where(.header) .session-tabs-host > .session-tabs.tabs-clusters .tab-name-case' &&
r.decls.display === 'inline'
),
`case part of the name shown at ${width}px`
).toBe(true);
}
});
it('leaves the desktop strip (768px and up) to styles.css: no mobile.css cluster rule reaches it', () => {
// mobile.css is linked up to 1023px, and from 768 getDeviceType() says
// 'desktop': the boxes keep their width there and the strip wraps box by
// box. The tablet block of mobile.css runs to 768 inclusive, so a cluster
// rule put inside it would dissolve the boxes at exactly 768.
const rules = mobileClusterRules();
expect(rules.length).toBeGreaterThan(0);
for (const r of rules) {
expect(r.widths.max, `${r.selector} stops below 768px`).toBeLessThan(768);
}
});
});
+67
View File
@@ -12,6 +12,9 @@
* literal silently replaces the first).
* - The state-row headings the strip draws (CodemanTabTriage's labels)
* translate too.
* - The Bottom bar group (the Git status rows, #543's max repositories and
* git timeout among them) translates as one group: heading, every label and
* description, and the Git panel's names that the group's key now reaches.
*
* The rows' `desktop` tag is a generic tag shared with rows older than #538
* (WebGL Renderer) and is left out of the check.
@@ -115,3 +118,67 @@ describe('the Tab Layout settings rows in zh-CN', () => {
expect(labels.filter((s) => api.t(s) === s || leftover(api.t(s)).length)).toEqual([]);
});
});
/** The Bottom bar group: its heading, then each row's label (without its tag) and description. */
function bottomBarStrings(doc: Document): Map<string, string> {
const out = new Map<string, string>();
const group = doc.getElementById('appSettingsShowGitStatus')!.closest('.set-group')!;
out.set('heading', group.querySelector('h4')!.textContent!.trim());
for (const row of group.querySelectorAll('.set-row')) {
const id = row.querySelector('input, select')!.id;
const label = row.querySelector('.set-row-label')!.cloneNode(true) as Element;
label.querySelector('.set-scope')?.remove();
out.set(`${id} label`, label.textContent!.trim());
out.set(`${id} description`, row.querySelector('.set-row-desc')!.textContent!.trim());
}
return out;
}
describe('the Bottom bar settings group in zh-CN', () => {
const englishGroup = bottomBarStrings(source.window.document);
const chineseGroup = bottomBarStrings(dom.window.document);
/** What may stay Latin here: the git and Docker names, and the product name. */
const left = (text: string) => text.replace(/\b(Git|git|Docker|Codeman)\b/g, '').match(/[A-Za-z]+/g) ?? [];
it("finds the heading and the four Git status rows, #543's two included (the check is not vacuous)", () => {
expect(englishGroup.size).toBe(9);
expect(englishGroup.get('heading')).toBe('Bottom bar');
expect(englishGroup.get('appSettingsGitStatusMaxRepos label')).toBe('Git status: max repositories');
expect(englishGroup.get('appSettingsGitStatusTimeout label')).toBe('Git status: git timeout');
});
it('translates the heading, every label and every description, with no English left', () => {
const bad: string[] = [];
for (const [where, text] of chineseGroup) {
if (text === englishGroup.get(where) || left(text).length) bad.push(`${where}: "${text}"`);
}
expect(bad).toEqual([]);
expect(chineseGroup.get('appSettingsGitStatusMaxRepos label')).toBe('Git 状态:最多仓库数');
});
it('reads exactly as before in English, and adds each key once', () => {
const en = new JSDOM('<!doctype html><html><body></body></html>', {
runScripts: 'outside-only',
url: 'http://localhost/',
});
vm.runInContext(I18N, en.getInternalVMContext(), { filename: 'i18n.js' });
const t = (en.window as unknown as { CodemanI18n: Api }).CodemanI18n;
t.configure({ language: 'en' });
expect([...englishGroup.values()].filter((s) => t.t(s) !== s)).toEqual([]);
en.window.close();
const repeated = [...englishGroup.values()].filter((s) => {
const key = s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return (I18N.match(new RegExp(`^\\s*(?:'${key}'|"${key}"|${key}):`, 'gm')) ?? []).length !== 1;
});
expect(repeated).toEqual([]);
});
it("the Git panel's names, which the group's 'Git status' key reaches too, read naturally", () => {
const doc = dom.window.document;
expect(doc.getElementById('gitStatusPanel')!.getAttribute('aria-label')).toBe('Git 状态');
const labels = [...doc.querySelectorAll('#gitStatusPanel .git-status-actions button')].map((b) =>
b.getAttribute('aria-label')
);
expect(labels).toEqual(['刷新 Git 状态', '关闭 Git 状态']);
});
});
+55 -1
View File
@@ -22,7 +22,7 @@
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FakeTile, makeGridApp, resetGridHarness, type GridApp } from './mocks/tile-grid-vm.js';
import { FakeTile, flushFrames, makeGridApp, resetGridHarness, type GridApp } from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c'];
@@ -148,3 +148,57 @@ describe('a tiled session deleted elsewhere', () => {
expect(app.showWelcome).toHaveBeenCalled();
});
});
// The handoffs above are the APP's choice: focus moves (activeSessionId, the
// focus paint) but the keyboard does not. Moving DOM focus into the
// neighbour's xterm sent whatever the user was still typing, Enter included,
// into another session's agent; the single view sends it nowhere. Only a
// removal the user made (the x button, Remove Focused Tile) carries the
// keyboard along.
describe('an app-driven refocus never moves the keyboard into another session', () => {
const live = (id: string) => FakeTile.all.find((t) => t.sessionId === id && !t._destroyed)!;
const focusCalls = () => FakeTile.all.reduce((n, t) => n + t.terminal.focus.mock.calls.length, 0);
/** A grid on IDS focused on s-b, every terminal built, no focus recorded yet. */
function built() {
const app = makeGridApp(IDS);
app.openTileGrid(IDS, { focusedId: 's-b' });
flushFrames();
for (const t of FakeTile.all) t.terminal.focus.mockClear();
return app;
}
it('a remote delete of the focused tile hands focus to the neighbour, DOM focus untouched', () => {
const app = built();
app._onSessionDeleted({ id: 's-b' });
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
expect(app.activeSessionId).toBe('s-c');
expect(app._tileGrid.focusedId).toBe('s-c');
expect(focusCalls()).toBe(0);
// No deferred focus left to land later either.
expect(app._tileGrid.focusOnConnect ?? null).toBeNull();
});
it.each([4003, 4004, 4010])('a socket the server closed (%i) removes the tile, DOM focus untouched', (code) => {
const app = built();
app._onTileExit('s-b', live('s-b'), code);
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
expect(app.activeSessionId).toBe('s-c');
expect(focusCalls()).toBe(0);
});
it('a reconcile that finds the focused session gone moves focus, DOM focus untouched', () => {
const app = built();
app.sessions.delete('s-b');
app._reconcileTileGrid();
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
expect(app.activeSessionId).toBe('s-a');
expect(focusCalls()).toBe(0);
});
it('a removal the user made still carries the keyboard to the neighbour', () => {
const app = built();
app.removeFocusedTile();
expect(app.activeSessionId).toBe('s-c');
expect(live('s-c').terminal.focus).toHaveBeenCalledTimes(1);
});
});
+131
View File
@@ -0,0 +1,131 @@
/**
* @fileoverview A file dragged onto the tile grid.
*
* The single view's file drop (image-input.js) listens on #terminalContainer,
* which is hidden while tiles are open. Nothing else cancelled a file drag,
* so dropping a screenshot on a tile made the browser open the file in place
* of Codeman. Now the grid section itself takes every file drag (bubble
* phase): anywhere over it (a tile, an empty cell, a divider, its padding)
* dragover and drop are cancelled, so the page never navigates, and a drop on
* a tile uploads its images to THAT tile's session, the same upload and the
* same "Only image files" toast as the single view. Tab and tile drags are
* the targets' own (_acceptTabDrops, capture phase) and stay untouched.
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FakeEl, makeGridApp, resetGridHarness, section, tileEl, type GridApp } from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c'];
function fileEvent(target: FakeEl, files: Array<{ type: string; name?: string }>, types = ['Files']) {
return {
target,
dataTransfer: { types, files, dropEffect: 'none' },
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
};
}
/** A grid on `ids`, s-a focused, uploads stubbed. Five tiles make a 3x2 with one empty cell. */
function gridApp(ids = IDS): GridApp {
const app = makeGridApp(ids);
app._uploadAndInsertImages = vi.fn();
app.openTileGrid(ids, { focusedId: 's-a' });
return app;
}
const slot = () => section.children.find((el) => el.classList.contains('tile-slot'))!;
const PNG = { type: 'image/png', name: 'shot.png' };
beforeEach(() => {
resetGridHarness();
});
describe('a file dropped on a tile', () => {
it("uploads its images to that tile's session (not the focused one), the page staying put", () => {
const app = gridApp();
// Deep inside the tile, as on xterm's rows or its helper textarea.
const inner = tileEl('s-b').querySelector('.tile-body')!;
const over = fileEvent(inner, [PNG]);
section.dispatch('dragover', over);
expect(over.preventDefault).toHaveBeenCalled();
expect(over.dataTransfer.dropEffect).toBe('copy');
const drop = fileEvent(inner, [PNG, { type: 'image/jpeg' }]);
section.dispatch('drop', drop);
expect(drop.preventDefault).toHaveBeenCalled();
expect(app._uploadAndInsertImages).toHaveBeenCalledTimes(1);
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([PNG, { type: 'image/jpeg' }], { sessionId: 's-b' });
// A drop is not a selection: focus stays where it was.
expect(app.activeSessionId).toBe('s-a');
});
it('only the images of a mixed drop are uploaded', () => {
const app = gridApp();
section.dispatch('drop', fileEvent(tileEl('s-c'), [{ type: 'text/plain' }, PNG]));
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([PNG], { sessionId: 's-c' });
});
it('a drop with no image says so, as the single view does, and uploads nothing', () => {
const app = gridApp();
const drop = fileEvent(tileEl('s-b'), [{ type: 'application/pdf' }]);
section.dispatch('drop', drop);
expect(drop.preventDefault).toHaveBeenCalled();
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
expect(app.showToast).toHaveBeenCalledWith('Only image files are supported', 'error');
});
});
describe('a file dragged anywhere else over the grid', () => {
it('an empty cell, a divider or the padding: cancelled (no navigation), nothing uploaded', () => {
const app = gridApp([...IDS, 's-d', 's-e']);
for (const target of [slot(), section]) {
expect(target).toBeTruthy();
const over = fileEvent(target, [PNG]);
section.dispatch('dragover', over);
expect(over.preventDefault).toHaveBeenCalled();
const drop = fileEvent(target, [PNG]);
section.dispatch('drop', drop);
expect(drop.preventDefault).toHaveBeenCalled();
}
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
expect(app.showToast).not.toHaveBeenCalled();
});
});
describe('tab and tile drags are left to their targets', () => {
it('a drag that carries no files is not touched by the grid section', () => {
const app = gridApp();
app.draggedTabId = 's-other';
const over = fileEvent(section, [], ['text/plain']);
section.dispatch('dragover', over);
section.dispatch('drop', over);
expect(over.preventDefault).not.toHaveBeenCalled();
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
});
it("a tab dropped on a tile still replaces it through the tile's own handler", () => {
const app = gridApp();
app.draggedTabId = 's-other';
const drop = fileEvent(tileEl('s-b'), [], ['text/plain']);
tileEl('s-b').dispatch('drop', drop);
expect(app._tileGrid.ids).toContain('s-other');
expect(app._tileGrid.ids).not.toContain('s-b');
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
});
});
describe('the guard is installed once', () => {
it('opening and closing the grid again never stacks listeners', () => {
const app = gridApp();
app.closeTileGrid({ reselect: false });
app.openTileGrid(IDS);
app.closeTileGrid({ reselect: false });
app.openTileGrid(IDS);
expect(section.listeners.dragover).toHaveLength(1);
expect(section.listeners.drop).toHaveLength(1);
// Bubble phase: the tab and tile drop targets stop their own drags in capture.
expect(section.captureFlags.drop).toEqual([false]);
});
});
+78 -3
View File
@@ -8,12 +8,15 @@
* writes text: the count menu (cap and window wording), the Attach
* overlay (not attached, attaching, exited, ended), zoom, the header
* tooltip, the dividers, the empty slot, every toast, the crash-restart
* confirm, the Tiles and Split button titles. Each must translate to text
* with no Latin word left beyond key names and durations, and read
* unchanged in English.
* confirm, the Tiles and Split button titles, the loading label (a body
* attribute, shown as CSS generated content) and the rename field's name.
* Each must translate to text with no Latin word left beyond key names and
* durations, and read unchanged in English.
* - Static strings: the shortcut registry's tile entries (overlay and App
* Settings list), and index.html run through the real translator in JSDOM
* (the Tiles button, the App Settings chips, the Help modal's Tiles rows).
* - No words in the tile CSS: generated content (`content: '...'`) is out of
* the translator's reach, so a tile rule may carry glyphs, never text.
* - User text stays as typed: session names (tile header) and group names
* carry data-i18n-skip, and a session name inside the
* confirm passes through the pattern untranslated.
@@ -24,6 +27,7 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import postcss from 'postcss';
import { afterAll, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
@@ -33,6 +37,7 @@ import {
makeGridApp,
resetGridHarness,
section,
tileEl,
windowStub,
type GridApp,
} from './mocks/tile-grid-vm.js';
@@ -102,6 +107,8 @@ function harvest(root: FakeEl | null | undefined, where: string) {
add(el.title, 'title');
add(el.attrs['aria-label'], 'aria-label');
add(el.attrs.title, 'title attribute');
// The loading label: CSS shows it (content: attr(data-loading-label)).
add(el.dataset.loadingLabel, 'loading label');
for (const child of el.children) walk(child, skip);
};
if (root) walk(root, false);
@@ -215,6 +222,19 @@ async function exercise() {
harvestAll(app, 'zoomed');
app.zoomTile('s-1');
// Renaming a tile: the input in the name's place, then Escape.
app.startTileRename('s-1');
harvestAll(app, 'renaming');
tileEl('s-1').querySelector('.tile-rename-input')!.dispatch('keydown', { key: 'Escape', preventDefault: vi.fn() });
// A file that is not an image dropped on a tile.
section.dispatch('drop', {
target: section.children.find((el) => el.dataset.sessionId === 's-1'),
dataTransfer: { types: ['Files'], files: [{ type: 'application/pdf' }] },
preventDefault: vi.fn(),
});
harvestAll(app, 'file drop');
// The toasts of a full grid, by the cap and by the window.
app._joinTileGridFromRun('s-7');
app.addSessionToTiles('s-7');
@@ -314,10 +334,13 @@ describe('every tile grid string the code puts on screen translates to zh-CN', (
'Could not attach the session',
'This group has no session to show as tiles',
'No sessions to show as tiles',
'Only image files are supported',
'Split: unavailable while tiles are open',
'Tiles: show several sessions side by side (right-click for how many)',
'Tiles: back to a single session (right-click for how many tiles)',
'6 tiles was stopped after crashing repeatedly. Restart it?',
'Loading…',
'Session name',
];
const missing = expected.filter((s) => !seen.has(s));
expect(missing).toEqual([]);
@@ -462,3 +485,55 @@ describe('user text in the tile code', () => {
expect(zh.api.t(confirmText[0])).toBe('6 tiles 因反复崩溃已被停止。要重启吗?');
});
});
describe('the loading label: CSS generated content, written in the UI language', () => {
it('goes through the translator when the tile is built, and again each time the tile starts loading', () => {
setUp();
const Queue = windowStub.TileLoadQueue;
const queue: { onChange?: (tile: unknown, state: string) => void } = {};
windowStub.TileLoadQueue = class {
constructor(opts: { onChange: (tile: unknown, state: string) => void }) {
queue.onChange = opts.onChange;
}
schedule() {}
drop() {}
};
try {
windowStub.codemanT = zh.api.t;
const app = makeGridApp(EIGHT);
app.openTileGrid(EIGHT.slice(0, 2));
const entry = app._tileGrid.tiles.get('s-1');
expect(entry.body.dataset.loadingLabel).toBe('加载中…');
// A language switched since is picked up the next time the tile loads.
windowStub.codemanT = en.api.t;
app._tileLoadQueue();
queue.onChange!(entry.tile, 'queued');
expect(entry.el.classList.contains('tile--loading')).toBe(true);
expect(entry.body.dataset.loadingLabel).toBe('Loading…');
queue.onChange!(entry.tile, 'idle');
expect(entry.el.classList.contains('tile--loading')).toBe(false);
} finally {
windowStub.TileLoadQueue = Queue;
delete windowStub.codemanT;
}
});
it('no tile rule carries words in generated content (the translator cannot reach it)', () => {
const bad: string[] = [];
const contents = new Map<string, string>();
postcss.parse(read('styles.css')).walkRules((rule) => {
if (!/\.tile\b/.test(rule.selector)) return;
rule.walkDecls('content', (decl) => {
contents.set(rule.selector, decl.value);
// CSS escapes are glyphs (\2026, \00B7), not words.
const literals = [...decl.value.matchAll(/'([^']*)'|"([^"]*)"/g)].map((m) =>
(m[1] ?? m[2]).replace(/\\[0-9a-fA-F]{1,6}\s?/g, '')
);
if (literals.some((text) => /[A-Za-z]/.test(text))) bad.push(`${rule.selector} { content: ${decl.value} }`);
});
});
// Not vacuous: the loading label is one of the rules read.
expect(contents.get('.tile.tile--loading .tile-body::after')).toBe('attr(data-loading-label)');
expect(bad).toEqual([]);
});
});
+62 -2
View File
@@ -24,6 +24,7 @@
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import postcss, { type AtRule, type Rule } from 'postcss';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
@@ -279,7 +280,15 @@ describe('the CSS', () => {
};
it('every new keyframe animates opacity and transform only', () => {
for (const name of ['tile-enter', 'tile-ghost-dim', 'tile-leave', 'tile-leave-now', 'tile-loading-breathe', 'tile-count-menu-in']) {
for (const name of [
'tile-enter',
'tile-ghost-dim',
'tile-leave',
'tile-leave-now',
'tile-loading-breathe',
'tile-count-menu-in',
'tile-needs-pulse',
]) {
const body = keyframes(name);
expect(body, name).not.toBe('');
const props = [...body.matchAll(/^\s*([a-z-]+):/gm)].map((m) => m[1]);
@@ -300,11 +309,62 @@ describe('the CSS', () => {
it('nothing moves under prefers-reduced-motion, and a web tab hides the copy', () => {
const block = css.slice(css.indexOf('@media (prefers-reduced-motion: reduce) {\n .tile-count-menu,'));
const reduced = block.slice(0, block.indexOf('\n}\n'));
for (const sel of ['.tile-count-menu', '.tile.tile--entering', '.tile.tile--loading .tile-body::after', '.tile-grid-ghosts .tile.tile--leaving']) {
for (const sel of [
'.tile-count-menu',
'.tile.tile--entering',
'.tile.tile--needs::after',
'.tile.tile--loading .tile-body::after',
'.tile-grid-ghosts .tile.tile--leaving',
]) {
expect(reduced).toContain(sel);
}
expect(reduced).toMatch(/animation: none;/);
expect(reduced).toMatch(/\.tile-body \.xterm \{\s*transition: none;/);
expect(css).toMatch(/\.main\.webview-active \.tile-grid-ghosts \{\s*display: none;/);
});
it('the needs-you pulse fades a static glow overlay in and out, never repainting the tile itself', () => {
// A needs tile pulses for as long as its prompt waits, hours at a time. A
// box-shadow animated on the tile repainted the whole tile (its DOM-rendered
// terminal rows with it, the whole stage when zoomed) every frame.
const rules: Array<{ selector: string; media: string; decls: Record<string, string> }> = [];
postcss.parse(css).walkRules((rule: Rule) => {
if (!rule.selector.includes('tile--needs')) return;
const decls: Record<string, string> = {};
rule.walkDecls((d) => {
decls[d.prop] = d.value;
});
const media = rule.parent?.type === 'atrule' ? (rule.parent as AtRule).params : '';
for (const selector of rule.selectors) rules.push({ selector, media, decls });
});
const find = (selector: string, media = '') => rules.filter((r) => r.selector === selector && r.media === media);
// The tile keeps its red border and animates nothing.
const [tile] = find('.tile.tile--needs');
expect(tile.decls['border-color']).toContain('var(--red');
expect(tile.decls.animation).toBeUndefined();
expect(tile.decls['box-shadow']).toBeUndefined();
// The glow is an overlay: a STATIC inset shadow (.tile is overflow: hidden
// and clips an outer one), above the Attach overlay, out of the pointer's way.
const [glow] = find('.tile.tile--needs::after');
expect(glow.decls.content).toBe("''");
expect(glow.decls.position).toBe('absolute');
expect(glow.decls.inset).toBe('0');
expect(glow.decls['pointer-events']).toBe('none');
expect(Number(glow.decls['z-index'])).toBeGreaterThan(2);
expect(glow.decls['box-shadow']).toMatch(/^inset /);
expect(glow.decls.animation).toMatch(/^tile-needs-pulse /);
// Only that overlay runs the pulse: no rule restates it on the tile (the old
// entering-and-needs shorthand would now blink the whole tile's opacity).
const pulsing = rules.filter((r) => /tile-needs-pulse/.test(r.decls.animation ?? ''));
expect(pulsing.map((r) => r.selector)).toEqual(['.tile.tile--needs::after']);
expect(rules.some((r) => r.selector.includes('tile--entering') && r.selector.includes('tile--needs'))).toBe(false);
// Reduced motion: no pulse, and a static ring on the tile instead.
const reduce = '(prefers-reduced-motion: reduce)';
expect(find('.tile.tile--needs::after', reduce).some((r) => r.decls.display === 'none')).toBe(true);
expect(find('.tile.tile--needs', reduce).some((r) => /^0 0 0 2px /.test(r.decls['box-shadow'] ?? ''))).toBe(true);
});
});
+148 -1
View File
@@ -2,7 +2,8 @@
// Port: none (pure static analysis, runs in CI, no browser/server).
//
// `showTileGridButton` (the header Tiles button, and the Ctrl+Shift+G chord) is
// a PER-DEVICE setting (default ON on desktop, OFF on handhelds) with the same three-way rule as
// a PER-DEVICE setting (default ON on desktop, OFF on handhelds and touch-primary
// tablets) with the same three-way rule as
// showSplitButton (test/split-pane-per-device-setting.test.ts, read it for the
// history): in settings-ui.js's displayKeys merge policy, stripped out of the
// object saveAppSettings() PUTs (SettingsUpdateSchema is .strict(), so sending
@@ -14,7 +15,9 @@ import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { makeGridApp } from './mocks/tile-grid-vm.js';
const HERE = fileURLToPath(new URL('.', import.meta.url));
const PUBLIC = join(HERE, '../src/web/public');
@@ -59,6 +62,150 @@ describe('showTileGridButton stays per-device: display key, stripped from the PU
});
});
// The real getDefaultSettings() (settings-ui.js) on each kind of device. A
// touch-primary tablet (iPad, an Android tablet: primary pointer coarse) is
// not a handheld, so it used to take the desktop defaults and get the Tiles
// button ON, although a tile has none of the main terminal's touch, IME and
// soft-keyboard handling. It defaults OFF there (opt-in, like Split); a
// touchscreen laptop (primary pointer fine) keeps the desktop default.
describe('the device default: ON on desktop, OFF on handhelds and touch-primary tablets', () => {
const SOURCE = readFileSync(join(PUBLIC, 'settings-ui.js'), 'utf8');
type Device = { handheld: boolean; touch: boolean; coarse: boolean };
function defaultsOn({ handheld, touch, coarse }: Device) {
const CodemanApp = function CodemanApp(this: unknown) {};
const context = vm.createContext({
CodemanApp,
VoiceInput: {},
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: () => null },
console,
MobileDetection: {
isHandheldDevice: () => handheld,
isTouchDevice: () => touch,
getDeviceType: () => (handheld ? 'mobile' : 'desktop'),
},
window: {
matchMedia: (q: string) => ({
matches: q === '(pointer: coarse)' ? coarse : q === '(pointer: fine)' && !coarse,
}),
},
});
vm.runInContext(SOURCE, context, { filename: 'settings-ui.js' });
const app = Object.create(CodemanApp.prototype) as { getDefaultSettings(): Record<string, unknown> };
return app.getDefaultSettings();
}
/** What every reader resolves an absent key to: the button, the App Settings chip and the chord. */
const resolved = (d: Device) => (defaultsOn(d).showTileGridButton ?? true) === true;
const DESKTOP = { handheld: false, touch: false, coarse: false };
const TOUCH_LAPTOP = { handheld: false, touch: true, coarse: false };
const TABLET = { handheld: false, touch: true, coarse: true };
const PHONE = { handheld: true, touch: true, coarse: true };
it('a desktop and a touchscreen laptop (fine primary pointer): ON', () => {
expect(defaultsOn(DESKTOP).showTileGridButton).toBeUndefined();
expect(resolved(DESKTOP)).toBe(true);
expect(defaultsOn(TOUCH_LAPTOP).showTileGridButton).toBeUndefined();
expect(resolved(TOUCH_LAPTOP)).toBe(true);
});
it('a touch-primary tablet (coarse primary pointer, not a handheld): OFF, and only that key changes', () => {
expect(defaultsOn(TABLET)).toEqual({ showTileGridButton: false });
expect(resolved(TABLET)).toBe(false);
});
it('a handheld: OFF, as before', () => {
expect(defaultsOn(PHONE).showTileGridButton).toBe(false);
});
it('never stores the posture default: a 2-in-1 first opened as a tablet gets the button once it is docked', () => {
// A fresh device caches loadAppSettingsFromStorage()'s fallback, and the
// server-settings merge saves that object (saveAppSettingsToStorage(merged)),
// so a default taken from the instantaneous primary pointer must not be in it.
let coarse = true;
const stored = new Map<string, string>();
const CodemanApp = function CodemanApp(this: unknown) {};
const context = vm.createContext({
CodemanApp,
VoiceInput: {},
localStorage: {
getItem: (k: string) => stored.get(k) ?? null,
setItem: (k: string, v: string) => stored.set(k, String(v)),
},
document: { getElementById: () => null },
console,
MobileDetection: {
isHandheldDevice: () => false,
isTouchDevice: () => true,
getDeviceType: () => 'desktop',
},
window: {
matchMedia: (q: string) => ({
matches: q === '(pointer: coarse)' ? coarse : q === '(pointer: fine)' && !coarse,
}),
},
});
vm.runInContext(SOURCE, context, { filename: 'settings-ui.js' });
type SettingsApp = {
getDefaultSettings(): Record<string, unknown>;
loadAppSettingsFromStorage(): Record<string, unknown>;
saveAppSettingsToStorage(s: Record<string, unknown>): void;
};
const app = Object.create(CodemanApp.prototype) as SettingsApp;
// What every reader resolves (header button, App Settings chip, chord).
const resolvedNow = () =>
(app.loadAppSettingsFromStorage().showTileGridButton ?? app.getDefaultSettings().showTileGridButton ?? true) ===
true;
// First load in tablet posture: OFF, and nothing about it is cached or saved.
expect(resolvedNow()).toBe(false);
expect(app.loadAppSettingsFromStorage()).not.toHaveProperty('showTileGridButton');
app.saveAppSettingsToStorage({ ...app.loadAppSettingsFromStorage() });
expect([...stored.values()].join('')).not.toContain('showTileGridButton');
// Docked (keyboard and trackpad: fine primary pointer): the desktop default.
coarse = false;
expect(resolvedNow()).toBe(true);
});
it('a context with no window at all still answers (the desktop default)', () => {
const CodemanApp = function CodemanApp(this: unknown) {};
const context = vm.createContext({
CodemanApp,
VoiceInput: {},
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: () => null },
console,
MobileDetection: { isHandheldDevice: () => false, getDeviceType: () => 'desktop' },
});
vm.runInContext(SOURCE, context, { filename: 'settings-ui.js' });
const app = Object.create(CodemanApp.prototype) as { getDefaultSettings(): Record<string, unknown> };
expect(app.getDefaultSettings()).toEqual({});
});
it('the Ctrl+Shift+G chord follows the same default: inert on a tablet with nothing stored, live on a touchscreen laptop', () => {
const TOGGLE = {
type: 'keydown',
key: 'G',
code: 'KeyG',
ctrlKey: true,
shiftKey: true,
metaKey: false,
altKey: false,
target: { closest: () => null },
};
for (const [device, applies] of [
[TABLET, false],
[TOUCH_LAPTOP, true],
] as const) {
const app = makeGridApp(['s-a', 's-b']);
app.loadAppSettingsFromStorage = () => ({});
app.getDefaultSettings = () => defaultsOn(device);
expect(app.tileShortcutFor(TOGGLE)).toBe(applies ? 'toggle-tile-grid' : null);
}
});
});
describe('App Settings search finds Split and Tiles', () => {
// The real filter (settings-ui.js) over the real markup: it matches each chip
// by its own data-search and its text, so the chips carry their own keywords.
+29
View File
@@ -201,6 +201,35 @@ describe('moving focus between tiles', () => {
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
});
// The last tile popped out closes the grid. The single view must never try
// to show that session: selectSession only raises its window, and the parked
// terminal's stale pre-grid content would stay up under its tab (and be
// saved as its snapshot on the next switch).
it('popping out the LAST tile falls through to the next session, never the popped-out one', () => {
const app = makeGridApp(['s-a']);
app.openTileGrid(['s-a']);
app.$ = () => null;
app.selectSession = vi.fn();
app._markDetached('s-a', true);
expect(app._tilesOwnTerminal()).toBe(false);
expect(app.selectSession.mock.calls).toEqual([['s-other', { auto: true }]]);
// Nulled before the selection, so the parked buffer is not snapshotted as s-a's.
expect(app.activeSessionId).toBeNull();
});
it('popping out the last tile with every other session popped out too: the welcome screen, the parked terminal cleared', () => {
const app = makeGridApp(['s-a']);
app.detachedSessions.add('s-other');
app.openTileGrid(['s-a']);
app.$ = () => null;
app.selectSession = vi.fn();
app._markDetached('s-a', true);
expect(app.selectSession).not.toHaveBeenCalled();
expect(app.activeSessionId).toBeNull();
expect(app.terminal.clear).toHaveBeenCalled();
expect(app.showWelcome).toHaveBeenCalled();
});
it('going Home leaves the grid, remembered', () => {
const app = openGrid();
app.goHome();
+67
View File
@@ -83,6 +83,7 @@ describe('opening the grid over an open split', () => {
app.activeSessionId = 's-a';
localStore.set('codeman:tile-count', '2');
const paneB = openSplit(app);
app.sendResize.mockClear();
app.toggleTileGrid();
@@ -91,6 +92,8 @@ describe('opening the grid over an open split', () => {
// Filled from tab order (s-other first), not seeded with the split's two.
expect(app._tileGrid.ids).toEqual(['s-c', 's-other']);
expect(app.activeSessionId).toBe('s-c');
// Pane A (s-a) is not a tile: the split's closing resize gave it its full width back.
expect(app.sendResize.mock.calls).toEqual([['s-a', { force: true }]]);
});
it('an explicit open over a split keeps both split sessions first', () => {
@@ -100,6 +103,70 @@ describe('opening the grid over an open split', () => {
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']);
expect(app.activeSessionId).toBe('s-a');
});
// The entry points that size their own set never get the split's two
// prepended on top of it (that went past the group, the count and the
// window's capacity, and auto-zoomed with a "too small" toast).
it("'Open group as tiles' over a split opens exactly the group, the split closed", () => {
const app = makeGridApp([...IDS, 's-d']);
app.tabLayout = {
groups: [
{
id: 'g',
name: 'G',
refs: [
{ kind: 'session', id: 's-c' },
{ kind: 'session', id: 's-d' },
],
},
],
};
const paneB = openSplit(app);
app.openGroupAsTiles('g');
expect(paneB.destroy).toHaveBeenCalledTimes(1);
expect(app._splitPane).toBeNull();
expect(app._tileGrid.ids).toEqual(['s-c', 's-d']);
expect(app.activeSessionId).toBe('s-c');
// Pane A (s-a) is not in the group, so no tile will ever size its PTY: the
// split's closing resize gives it its full width back before the main
// terminal parks, or it stays at the split's half width for as long as
// the grid is open. The split's opening resize, then the closing one.
expect(app.sendResize).toHaveBeenCalledTimes(2);
expect(app.sendResize).toHaveBeenLastCalledWith('s-a', { force: true });
});
it('Ctrl/Cmd+click over a split opens the remembered count in total, never one more', () => {
const app = makeGridApp(IDS);
localStore.set('codeman:tile-count', '2');
openSplit(app);
app.addSessionToTiles('s-c');
expect(app._splitPane).toBeNull();
// The split's Pane A seeds the set (tileGridOpenSet), the clicked one joins: 2, not 3.
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
expect(app.activeSessionId).toBe('s-c');
// Pane A is a tile, which sizes its PTY: no closing resize for it.
expect(app.sendResize).toHaveBeenCalledTimes(1); // the split's own opening resize only
});
it('Ctrl/Cmd+click over a split with a remembered grid: the remembered grid wins, not the split', () => {
const app = makeGridApp(IDS);
app.selectSession = vi.fn();
app.openTileGrid(['s-other', 's-c']);
app.closeTileGrid({ reselect: false });
app.activeSessionId = 's-a';
localStore.set('codeman:tile-count', '2');
openSplit(app);
app.addSessionToTiles('s-b');
expect(app._splitPane).toBeNull();
expect(app._tileGrid.ids).toEqual(['s-other', 's-b']);
expect(app.activeSessionId).toBe('s-b');
});
});
describe('while the grid is open', () => {
+84 -3
View File
@@ -8,6 +8,12 @@
* session. The target is now captured in `start()` (through `_focusedPane()`,
* so a second terminal pane can claim it later) and every send path uses it.
*
* With the tile grid open the main terminal (and with it the local-echo
* overlay) is parked with `display: none`, so direct-mode dictation used to be
* typed into an invisible overlay and never reached the focused tile. While
* `_tilesOwnTerminal()` is true the overlay is skipped and the keyboard goes
* back to the focused tile, not the parked main terminal.
*
* Loaded via `vm` with a stubbed `app` (no jsdom).
*/
import { readFileSync } from 'node:fs';
@@ -27,10 +33,13 @@ type Voice = {
_targetSessionId: string | null;
};
function load(opts: { insertMode?: string; localEcho?: boolean; focused?: string } = {}) {
type Pane = { sessionId: string; isPrimary?: boolean; terminal?: { focus: () => void } };
function load(opts: { insertMode?: string; localEcho?: boolean; focused?: string; tiles?: boolean; pane?: Pane } = {}) {
const sendInput = vi.fn(async () => {});
const sendInputAsync = vi.fn();
const appendText = vi.fn();
const overlayClear = vi.fn();
const showToast = vi.fn();
const gear = {
classList: { contains: () => false, add: vi.fn(), remove: vi.fn() },
@@ -53,9 +62,19 @@ function load(opts: { insertMode?: string; localEcho?: boolean; focused?: string
showToast,
terminal: { focus: vi.fn() },
_localEchoEnabled: !!opts.localEcho,
_localEchoOverlay: opts.localEcho ? { appendText, pendingText: '', clear: vi.fn() } : null,
_localEchoOverlay: opts.localEcho
? { appendText, pendingText: '', clear: overlayClear, suppressBufferDetection: vi.fn() }
: null,
_predictiveEcho: { clearPredictions: vi.fn() },
};
if (opts.focused) app._focusedPane = () => ({ sessionId: opts.focused });
// A full pane record, as terminal-ui.js _focusedPane() returns it. Reassign
// app._focusedPane in a test to move focus between panes.
if (opts.pane) {
const pane = opts.pane;
app._focusedPane = () => pane;
}
if (opts.tiles) app._tilesOwnTerminal = () => true;
const context = vm.createContext({
console,
setTimeout: (fn: () => void) => fn(),
@@ -81,7 +100,12 @@ function load(opts: { insertMode?: string; localEcho?: boolean; focused?: string
// Recording itself is out of scope: start() only has to pick the target.
voice._resolveProvider = () => 'webspeech';
voice._startWebSpeech = vi.fn();
return { voice, app, sendInput, sendInputAsync, appendText, showToast, gear };
return { voice, app, sendInput, sendInputAsync, appendText, overlayClear, showToast, gear };
}
function clickGreenSend(gear: { addEventListener: { mock: { calls: unknown[][] } } }) {
const handler = gear.addEventListener.mock.calls.find((c: unknown[]) => c[0] === 'click')?.[1] as () => void;
handler();
}
describe('dictation target', () => {
@@ -163,6 +187,63 @@ describe('dictation target', () => {
expect(showToast).toHaveBeenCalledWith('That session has closed; dictation not sent', 'warning');
});
it('with the tile grid open, dictation goes to the focused tile, not the parked overlay', () => {
const tileTerminal = { focus: vi.fn() };
const { voice, app, appendText, sendInput, sendInputAsync } = load({
localEcho: true,
tiles: true,
pane: { sessionId: 'session-a', isPrimary: false, terminal: tileTerminal },
});
voice.start();
voice._insertText('into the tile');
expect(appendText).not.toHaveBeenCalled();
expect(sendInput).toHaveBeenCalledWith('into the tile');
expect(sendInputAsync).not.toHaveBeenCalled();
// The keyboard goes back to the tile; the main terminal is display: none.
expect(tileTerminal.focus).toHaveBeenCalled();
expect((app.terminal as { focus: ReturnType<typeof vi.fn> }).focus).not.toHaveBeenCalled();
});
it('with the tile grid open, the green send button sends only Enter to the tile session', () => {
const tileTerminal = { focus: vi.fn() };
const { voice, app, gear, sendInput, overlayClear } = load({
localEcho: true,
tiles: true,
pane: { sessionId: 'session-a', isPrimary: false, terminal: tileTerminal },
});
voice.start();
voice._insertText('ship it');
// Something stale in the parked main overlay must not ride along.
(app._localEchoOverlay as { pendingText: string }).pendingText = 'stale main-terminal text';
clickGreenSend(gear);
expect(overlayClear).not.toHaveBeenCalled();
expect(sendInput).not.toHaveBeenCalledWith('stale main-terminal text');
expect(sendInput).toHaveBeenLastCalledWith('\r');
// The parked main terminal's predictions are not this pane's.
expect(
(app._predictiveEcho as { clearPredictions: ReturnType<typeof vi.fn> }).clearPredictions
).not.toHaveBeenCalled();
});
it('split view is unchanged: a Pane A dictation keeps the overlay and refocuses the main terminal', () => {
const paneB = { focus: vi.fn() };
const { voice, app, appendText, sendInput } = load({ localEcho: true, focused: 'session-a' });
voice.start();
// The user clicked into Pane B while speaking.
app._focusedPane = () => ({ sessionId: 'session-b', isPrimary: false, terminal: paneB });
voice._insertText('for pane a');
expect(appendText).toHaveBeenCalledWith('for pane a');
expect(sendInput).not.toHaveBeenCalled();
expect((app.terminal as { focus: ReturnType<typeof vi.fn> }).focus).toHaveBeenCalled();
expect(paneB.focus).not.toHaveBeenCalled();
});
it('refuses to start with no session at all', () => {
const { voice, app, showToast } = load();
app.activeSessionId = null;
+37
View File
@@ -0,0 +1,37 @@
/**
* @fileoverview Static guard: the wiki Home page's "Everything in the manual" index
* links every page the sidebar lists.
*
* docs/wiki/ is mirrored to the GitHub wiki, where _Sidebar.md shows on every page and
* Home.md calls itself the whole manual. A new page added to the sidebar alone (Tile
* Grid, Custom Model Endpoints) silently dropped out of that index. Compared by link
* target, since the two files label some pages differently.
*
* Port: N/A (pure static analysis).
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
const wiki = resolve(import.meta.dirname, '..', 'docs', 'wiki');
const read = (name: string) => readFileSync(resolve(wiki, name), 'utf8');
/** Internal wiki page targets of `[label](Target)` links, external URLs excluded. */
const pageTargets = (markdown: string): Set<string> =>
new Set(
[...markdown.matchAll(/\]\(([^)\s#]+)(?:#[^)]*)?\)/g)]
.map((m) => m[1])
.filter((target) => !/^[a-z]+:/i.test(target) && target !== 'Home')
);
describe('wiki Home index', () => {
it('links every page the sidebar lists', () => {
const sidebar = pageTargets(read('_Sidebar.md'));
expect(sidebar.size).toBeGreaterThan(20);
const index = read('Home.md').split('## Everything in the manual')[1] ?? '';
expect(index, 'Home.md has an "Everything in the manual" section').not.toBe('');
const indexed = pageTargets(index);
const missing = [...sidebar].filter((target) => !indexed.has(target));
expect(missing, 'add these to the "Everything in the manual" tables in docs/wiki/Home.md').toEqual([]);
});
});