mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
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:
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 状态']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user