mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-12 10:19:42 +02:00
Merge remote-tracking branch 'origin/master' into pr/cod-455-xlsx-preview
# Conflicts: # CLAUDE.md # config/test-suites.ts
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* @fileoverview Tests for Claude Code's advisor tool (code.claude.com/docs/en/advisor)
|
||||
* carried as a per-session `advisorModel`.
|
||||
*
|
||||
* The advisor rides the launch's ONE `--settings` JSON object as the `advisorModel` key,
|
||||
* never the `--advisor` flag: the flag exits at launch on a pairing the CLI refuses
|
||||
* (`claude --advisor haiku` prints "cannot be used as an advisor" and exits 1), while the
|
||||
* settings key degrades to "no advisor". Verified against Claude Code 2.1.289 with
|
||||
* `claude -p --settings '{"advisorModel":"opus"}' /advisor` → "Advisor: Opus 5.5".
|
||||
*
|
||||
* `--settings` is extracted through a REAL shell, as in statusline-cli-flag.test.ts, so the
|
||||
* assertions see exactly what a spawned pane would.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { buildAdvisorSettings, buildInteractiveArgs } from '../src/session-cli-builder.js';
|
||||
import { buildSpawnCommand } from '../src/tmux-manager.js';
|
||||
import { isAdvisorModel, ADVISOR_MODEL_ALIASES } from '../src/types.js';
|
||||
import { Session } from '../src/session.js';
|
||||
import {
|
||||
CreateSessionSchema,
|
||||
QuickStartSchema,
|
||||
RalphLoopStartSchema,
|
||||
SettingsUpdateSchema,
|
||||
} from '../src/web/schemas.js';
|
||||
|
||||
const EXPORTER_CMD = 'curl -sfk -X POST "$CODEMAN_API_URL/api/status-telemetry" --data @- 2>/dev/null || true';
|
||||
|
||||
/**
|
||||
* What the direct-PTY fallback hands `pty.spawn`. The file and argv are recorded, then a
|
||||
* harmless stand-in runs instead: the real `claude` must never start from a test, and the
|
||||
* stand-in is a real process so `Session.stop()` has a real pid to signal.
|
||||
*/
|
||||
const ptySpawns = vi.hoisted(() => [] as Array<{ file: string; args: string[] }>);
|
||||
vi.mock('node-pty', async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import('node-pty')>();
|
||||
return {
|
||||
...real,
|
||||
spawn: (file: string, args: string[] | string, options: import('node-pty').IPtyForkOptions) => {
|
||||
ptySpawns.push({ file, args: Array.isArray(args) ? args : [args] });
|
||||
return real.spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], options);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function extractSettingsJson(cmd: string): unknown {
|
||||
const idx = cmd.indexOf('--settings ');
|
||||
expect(idx).toBeGreaterThan(-1);
|
||||
const out = execFileSync('bash', ['-c', `set -- ${cmd.slice(idx)}; printf '%s' "$2"`]).toString();
|
||||
return JSON.parse(out);
|
||||
}
|
||||
|
||||
describe('isAdvisorModel', () => {
|
||||
it('accepts the documented aliases', () => {
|
||||
for (const alias of ADVISOR_MODEL_ALIASES) expect(isAdvisorModel(alias)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts full model ids in the advisor-capable families', () => {
|
||||
expect(isAdvisorModel('claude-opus-5-5')).toBe(true);
|
||||
expect(isAdvisorModel('claude-fable-5-1')).toBe(true);
|
||||
expect(isAdvisorModel('claude-sonnet-5-5')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects haiku, which can call an advisor but never act as one', () => {
|
||||
expect(isAdvisorModel('haiku')).toBe(false);
|
||||
expect(isAdvisorModel('claude-haiku-4-5-20251001')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects anything that could break out of the quoted --settings argument', () => {
|
||||
for (const bad of [
|
||||
'',
|
||||
'OPUS',
|
||||
'opus[1m]',
|
||||
"opus'; rm -rf /; '",
|
||||
'opus"}',
|
||||
'claude-opus-5-5 --dangerously-skip-permissions',
|
||||
`claude-opus-${'5-'.repeat(40)}5`,
|
||||
undefined,
|
||||
null,
|
||||
42,
|
||||
]) {
|
||||
expect(isAdvisorModel(bad)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAdvisorSettings', () => {
|
||||
it('returns the settings key for a valid model and nothing otherwise', () => {
|
||||
expect(buildAdvisorSettings('opus')).toEqual({ advisorModel: 'opus' });
|
||||
expect(buildAdvisorSettings(undefined)).toEqual({});
|
||||
expect(buildAdvisorSettings('haiku')).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSpawnCommand advisorModel (tmux launch, claude mode)', () => {
|
||||
it('rides --settings as the advisorModel key, never the --advisor flag', () => {
|
||||
const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', advisorModel: 'opus' });
|
||||
expect(cmd).not.toContain('--advisor');
|
||||
expect(cmd.match(/--settings/g)).toHaveLength(1);
|
||||
expect(extractSettingsJson(cmd)).toEqual({ advisorModel: 'opus' });
|
||||
});
|
||||
|
||||
it('merges ultracode, advisor and the statusLine exporter into ONE --settings object', () => {
|
||||
const cmd = buildSpawnCommand({
|
||||
mode: 'claude',
|
||||
sessionId: 'sid-1',
|
||||
effort: 'ultracode',
|
||||
advisorModel: 'fable',
|
||||
statusLineCommand: EXPORTER_CMD,
|
||||
});
|
||||
expect(cmd.match(/--settings/g)).toHaveLength(1);
|
||||
expect(extractSettingsJson(cmd)).toEqual({
|
||||
ultracode: true,
|
||||
advisorModel: 'fable',
|
||||
statusLine: { type: 'command', command: EXPORTER_CMD },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a regular --effort flag beside the advisor settings', () => {
|
||||
const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', effort: 'high', advisorModel: 'sonnet' });
|
||||
expect(cmd).toContain("--effort 'high'");
|
||||
expect(extractSettingsJson(cmd)).toEqual({ advisorModel: 'sonnet' });
|
||||
});
|
||||
|
||||
it('carries the advisor on the resume variant too', () => {
|
||||
const cmd = buildSpawnCommand({
|
||||
mode: 'claude',
|
||||
sessionId: 'sid-1',
|
||||
resumeSessionId: '11111111-2222-3333-4444-555555555555',
|
||||
advisorModel: 'opus',
|
||||
});
|
||||
expect(cmd).toContain('--resume');
|
||||
expect(extractSettingsJson(cmd)).toEqual({ advisorModel: 'opus' });
|
||||
});
|
||||
|
||||
it('leaves the command byte-identical when no advisor (or an invalid one) is set', () => {
|
||||
for (const base of [
|
||||
{ mode: 'claude', sessionId: 'sid-1' },
|
||||
{ mode: 'claude', sessionId: 'sid-1', effort: 'ultracode' as const, statusLineCommand: EXPORTER_CMD },
|
||||
]) {
|
||||
const without = buildSpawnCommand(base);
|
||||
expect(buildSpawnCommand({ ...base, advisorModel: undefined })).toBe(without);
|
||||
expect(buildSpawnCommand({ ...base, advisorModel: 'haiku' })).toBe(without);
|
||||
}
|
||||
});
|
||||
|
||||
it('is inert for a CLI that has no --settings carrier', () => {
|
||||
const cmd = buildSpawnCommand({ mode: 'codex', sessionId: 'sid-1', advisorModel: 'opus' });
|
||||
expect(cmd).not.toContain('advisorModel');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildInteractiveArgs advisorModel (direct-PTY fallback)', () => {
|
||||
const settingsOf = (args: string[]) => {
|
||||
expect(args.filter((a) => a === '--settings')).toHaveLength(1);
|
||||
return JSON.parse(args[args.indexOf('--settings') + 1]);
|
||||
};
|
||||
|
||||
it('adds a --settings object holding only the advisor', () => {
|
||||
const args = buildInteractiveArgs('sid', 'normal', undefined, undefined, undefined, undefined, null, 'opus');
|
||||
expect(args).not.toContain('--advisor');
|
||||
expect(settingsOf(args)).toEqual({ advisorModel: 'opus' });
|
||||
});
|
||||
|
||||
it("folds the advisor into ultracode's --settings object", () => {
|
||||
const args = buildInteractiveArgs('sid', 'normal', undefined, undefined, 'ultracode', undefined, null, 'fable');
|
||||
expect(settingsOf(args)).toEqual({ ultracode: true, advisorModel: 'fable' });
|
||||
});
|
||||
|
||||
it('keeps --effort beside it for a regular level', () => {
|
||||
const args = buildInteractiveArgs('sid', 'normal', undefined, undefined, 'max', undefined, null, 'sonnet');
|
||||
expect(args).toEqual(expect.arrayContaining(['--effort', 'max']));
|
||||
expect(settingsOf(args)).toEqual({ advisorModel: 'sonnet' });
|
||||
});
|
||||
|
||||
it('is unchanged without an advisor', () => {
|
||||
expect(buildInteractiveArgs('sid', 'normal', undefined, undefined, 'high', undefined, null, undefined)).toEqual(
|
||||
buildInteractiveArgs('sid', 'normal', undefined, undefined, 'high', undefined, null)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #514 (per-session `model` → `--model`) and #530 landed together. A claude session carrying
|
||||
* both must launch with `--model <id>` AND the advisor folded into the one `--settings` JSON,
|
||||
* whatever the effort, on the tmux template and on the direct-PTY fallback alike. Recovery of
|
||||
* the same pair is pinned in test/session-model-recovery.test.ts.
|
||||
*/
|
||||
describe('a per-session model together with an advisor', () => {
|
||||
const MODEL = 'claude-fable-5-1';
|
||||
const cases = [
|
||||
['ultracode', { ultracode: true, advisorModel: 'opus' }],
|
||||
['high', { advisorModel: 'opus' }],
|
||||
[undefined, { advisorModel: 'opus' }],
|
||||
] as const;
|
||||
|
||||
it.each(cases)('tmux template carries --model and the merged --settings, effort %s', (effort, settings) => {
|
||||
for (const resumeSessionId of [undefined, '11111111-2222-3333-4444-555555555555']) {
|
||||
const cmd = buildSpawnCommand({
|
||||
mode: 'claude',
|
||||
sessionId: 'sid-1',
|
||||
model: MODEL,
|
||||
effort,
|
||||
advisorModel: 'opus',
|
||||
resumeSessionId,
|
||||
claudeCliVersion: null,
|
||||
});
|
||||
// The resume variant renders `resume || new`, so the model appears once per branch.
|
||||
expect(cmd).toContain(`--model "${MODEL}"`);
|
||||
expect(cmd).not.toContain('--advisor ');
|
||||
const settingsFlags = cmd.match(/--settings /g) ?? [];
|
||||
expect(settingsFlags.length).toBe(resumeSessionId ? 2 : 1);
|
||||
expect(extractSettingsJson(cmd)).toEqual(settings);
|
||||
if (effort === 'high') expect(cmd).toContain("--effort 'high'");
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the statusLine exporter in the same object beside the model', () => {
|
||||
const cmd = buildSpawnCommand({
|
||||
mode: 'claude',
|
||||
sessionId: 'sid-1',
|
||||
model: MODEL,
|
||||
effort: 'ultracode',
|
||||
advisorModel: 'fable',
|
||||
statusLineCommand: EXPORTER_CMD,
|
||||
claudeCliVersion: null,
|
||||
});
|
||||
expect(cmd).toContain(`--model "${MODEL}"`);
|
||||
expect(cmd.match(/--settings /g)).toHaveLength(1);
|
||||
expect(extractSettingsJson(cmd)).toEqual({
|
||||
ultracode: true,
|
||||
advisorModel: 'fable',
|
||||
statusLine: { type: 'command', command: EXPORTER_CMD },
|
||||
});
|
||||
});
|
||||
|
||||
it.each(cases)('direct-PTY args carry --model and the merged --settings, effort %s', (effort, settings) => {
|
||||
const args = buildInteractiveArgs('sid', 'normal', MODEL, undefined, effort, undefined, null, 'opus');
|
||||
expect(args[args.indexOf('--model') + 1]).toBe(MODEL);
|
||||
expect(args.filter((a) => a === '--settings')).toHaveLength(1);
|
||||
expect(JSON.parse(args[args.indexOf('--settings') + 1])).toEqual(settings);
|
||||
if (effort === 'high') expect(args).toEqual(expect.arrayContaining(['--effort', 'high']));
|
||||
});
|
||||
|
||||
describe('a real Session on the direct-PTY fallback', () => {
|
||||
const live: Session[] = [];
|
||||
afterEach(async () => {
|
||||
for (const s of live.splice(0)) await s.stop();
|
||||
ptySpawns.length = 0;
|
||||
});
|
||||
|
||||
it.each(cases)('hands pty.spawn both, effort %s', async (effort, settings) => {
|
||||
const session = new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude',
|
||||
useMux: false,
|
||||
model: MODEL,
|
||||
advisorModel: 'opus',
|
||||
effort,
|
||||
});
|
||||
live.push(session);
|
||||
await session.startInteractive();
|
||||
|
||||
expect(ptySpawns).toHaveLength(1);
|
||||
const { args } = ptySpawns[0];
|
||||
expect(args[args.indexOf('--model') + 1]).toBe(MODEL);
|
||||
expect(args.filter((a) => a === '--settings')).toHaveLength(1);
|
||||
expect(JSON.parse(args[args.indexOf('--settings') + 1])).toEqual(settings);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session advisorModel', () => {
|
||||
it('stores a valid advisor and persists it through toState()', () => {
|
||||
const session = new Session({ workingDir: '/tmp', advisorModel: 'opus' });
|
||||
expect(session.toState().advisorModel).toBe('opus');
|
||||
});
|
||||
|
||||
it('drops an invalid value instead of forwarding it to the launch', () => {
|
||||
expect(new Session({ workingDir: '/tmp', advisorModel: 'haiku' }).toState().advisorModel).toBeUndefined();
|
||||
expect(new Session({ workingDir: '/tmp' }).toState().advisorModel).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('advisorModel request validation', () => {
|
||||
it.each([
|
||||
['CreateSessionSchema', CreateSessionSchema, { workingDir: '/tmp' }],
|
||||
['QuickStartSchema', QuickStartSchema, {}],
|
||||
['RalphLoopStartSchema', RalphLoopStartSchema, { taskDescription: 'x' }],
|
||||
] as const)('%s accepts advisor models and rejects the rest', (_name, schema, base) => {
|
||||
expect(schema.safeParse({ ...base, advisorModel: 'opus' }).success).toBe(true);
|
||||
expect(schema.safeParse({ ...base, advisorModel: 'claude-fable-5-1' }).success).toBe(true);
|
||||
expect(schema.safeParse({ ...base }).success).toBe(true);
|
||||
expect(schema.safeParse({ ...base, advisorModel: 'haiku' }).success).toBe(false);
|
||||
expect(schema.safeParse({ ...base, advisorModel: "opus'" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('SettingsUpdateSchema takes claudeAdvisorModel, with "" meaning the CLI default', () => {
|
||||
expect(SettingsUpdateSchema.safeParse({ claudeAdvisorModel: '' }).success).toBe(true);
|
||||
expect(SettingsUpdateSchema.safeParse({ claudeAdvisorModel: 'fable' }).success).toBe(true);
|
||||
expect(SettingsUpdateSchema.safeParse({ claudeAdvisorModel: 'haiku' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
// @vitest-environment node
|
||||
// capabilities.newline: the bytes Shift+Enter types into a CLI's pane. Data in the registry, not
|
||||
// a branch on the CLI id (test/cli-registry-no-id-branching.test.ts keeps the latter true).
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CliEntrySchema } from '../src/config/cli-registry/schema.js';
|
||||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||
import type { CliEntry } from '../src/config/cli-registry/types.js';
|
||||
|
||||
const claude = () => structuredClone(STOCK_CLIS.find((e) => (e.id as string) === 'claude')!) as CliEntry;
|
||||
|
||||
describe('capabilities.newline', () => {
|
||||
it('no stock CLI declares a chord: every one keeps the line feed', () => {
|
||||
// codex 0.147.0 takes a line feed (checked against a real tmux pane), so there is no CLI that
|
||||
// needs esc-enter yet. The capability exists for a user clis.json override and the next CLI.
|
||||
const declared = STOCK_CLIS.filter((e) => e.capabilities.newline).map((e) => e.id as string);
|
||||
expect(declared).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(['line-feed', 'esc-enter'])('schema accepts %s', (value) => {
|
||||
const e = claude();
|
||||
(e.capabilities as Record<string, unknown>).newline = value;
|
||||
expect(CliEntrySchema.safeParse(e).success).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['lf', 'crlf', '\x1b\r', '', 0])('schema rejects %j (no free-form byte strings in config)', (value) => {
|
||||
const e = claude();
|
||||
(e.capabilities as Record<string, unknown>).newline = value;
|
||||
expect(CliEntrySchema.safeParse(e).success).toBe(false);
|
||||
});
|
||||
|
||||
it('is optional, so an entry that declares nothing keeps the line feed', () => {
|
||||
const e = claude();
|
||||
delete (e.capabilities as Record<string, unknown>).newline;
|
||||
expect(CliEntrySchema.safeParse(e).success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getCli } from '../src/config/cli-registry/registry.js';
|
||||
import { buildSpawnCommandFromRegistry, type SpawnBridgeOptions } from '../src/session-cli-registry-bridge.js';
|
||||
import { CODEX_REASONING_EFFORTS } from '../src/types/session.js';
|
||||
|
||||
/** A fixed session id, so `--session-id` is stable across runs. */
|
||||
const SID = '0f9c2b14-1111-2222-3333-444455556666';
|
||||
@@ -54,6 +55,20 @@ describe('claude', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a model as the quoted value of --model, even one that opens with a dash', () => {
|
||||
// POST /api/sessions refuses a leading '-' in `model`, but the registry's `model-claude`
|
||||
// pattern still admits one, so the builder must stay safe on its own: the value lands
|
||||
// quoted, and Claude's option parser takes the word after `--model` as its value whatever
|
||||
// it starts with, so it can never become a flag of its own.
|
||||
expect(claude({ model: 'claude-fable-5-1' })).toBe(
|
||||
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" --model "claude-fable-5-1"'
|
||||
);
|
||||
expect(claude({ model: '--dangerously-skip-permissions' })).toBe(
|
||||
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" ' +
|
||||
'--model "--dangerously-skip-permissions"'
|
||||
);
|
||||
});
|
||||
|
||||
it('resumes through a shell fallback to a fresh session', () => {
|
||||
// The ` || ` is emitted by the ENGINE, not by config — no registry field can hold shell
|
||||
// text. This pin is what proves the fallback chain still renders as one command line.
|
||||
@@ -131,6 +146,18 @@ describe('codex', () => {
|
||||
it('resumes with a POSITIONAL subcommand, not a flag', () => {
|
||||
expect(cx({ model: 'gpt-5', resumeSessionId: 'roll_42' })).toBe('codex --model gpt-5 resume roll_42');
|
||||
});
|
||||
|
||||
it('sends reasoning effort as one model_reasoning_effort config value, for every level', () => {
|
||||
for (const level of CODEX_REASONING_EFFORTS) {
|
||||
expect(cx({ reasoningEffort: level })).toBe(`codex --config model_reasoning_effort=${level}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps reasoning effort ahead of the resume subcommand', () => {
|
||||
expect(cx({ model: 'gpt-5', reasoningEffort: 'high', resumeSessionId: 'roll_42' })).toBe(
|
||||
'codex --model gpt-5 --config model_reasoning_effort=high resume roll_42'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gemini', () => {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @fileoverview `codexConfig.reasoningEffort` on the create routes.
|
||||
*
|
||||
* The level becomes part of a `--config model_reasoning_effort=<level>` launch token, so the
|
||||
* schema admits only the words codex knows; anything else fails the request rather than
|
||||
* reaching the argv.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js';
|
||||
import { CODEX_REASONING_EFFORTS } from '../src/types/session.js';
|
||||
|
||||
describe('codexConfig.reasoningEffort', () => {
|
||||
it('accepts every level codex knows on both create routes', () => {
|
||||
for (const level of CODEX_REASONING_EFFORTS) {
|
||||
const created = CreateSessionSchema.parse({
|
||||
workingDir: '/tmp',
|
||||
mode: 'codex',
|
||||
codexConfig: { reasoningEffort: level },
|
||||
});
|
||||
expect(created.codexConfig?.reasoningEffort).toBe(level);
|
||||
const quick = QuickStartSchema.parse({
|
||||
caseName: 'work',
|
||||
mode: 'codex',
|
||||
codexConfig: { reasoningEffort: level },
|
||||
});
|
||||
expect(quick.codexConfig?.reasoningEffort).toBe(level);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a level codex does not know, and anything shaped like shell, on both create routes', () => {
|
||||
for (const reasoningEffort of ['bogus', 'HIGH', 'high; rm -rf /', '']) {
|
||||
expect(() =>
|
||||
CreateSessionSchema.parse({ workingDir: '/tmp', mode: 'codex', codexConfig: { reasoningEffort } })
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
QuickStartSchema.parse({ caseName: 'work', mode: 'codex', codexConfig: { reasoningEffort } })
|
||||
).toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
* hook definitions for desktop notifications.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
chmodSync,
|
||||
closeSync,
|
||||
@@ -26,6 +26,7 @@ import { spawn } from 'node:child_process';
|
||||
import {
|
||||
applyStatusLineConfig,
|
||||
ensureCodemanHooks,
|
||||
ensureStatusLineExporterScript,
|
||||
findEffectiveUserStatusLineCommand,
|
||||
generateBackgroundWakeScript,
|
||||
generateHooksConfig,
|
||||
@@ -1418,6 +1419,26 @@ describe('resolveStatusLineCliCommand', () => {
|
||||
expect(siblings).toEqual([]);
|
||||
});
|
||||
|
||||
it('survives concurrent refreshes in the same millisecond (sessions created at once)', async () => {
|
||||
const scriptPath = (await resolveStatusLineCliCommand(testDir, true))!;
|
||||
writeFileSync(scriptPath, '#!/bin/sh\n# CODEMAN_STATUSLINE_EXPORTER_V0\necho stale\n');
|
||||
// A frozen clock makes every writer agree on the timestamp, which is what two
|
||||
// session creates in one millisecond do. A temp name built from pid + Date.now()
|
||||
// is then shared: the first rename consumes it, every later rename fails ENOENT,
|
||||
// and that session's tmux create fell back to a direct PTY.
|
||||
const clock = vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000);
|
||||
try {
|
||||
const paths = await Promise.all(Array.from({ length: 8 }, () => ensureStatusLineExporterScript()));
|
||||
expect(new Set(paths)).toEqual(new Set([scriptPath]));
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
expect(readFileSync(scriptPath, 'utf-8')).not.toContain('echo stale');
|
||||
expect(statSync(scriptPath).mode & 0o111).not.toBe(0);
|
||||
const siblings = readdirSync(join(scriptPath, '..')).filter((f) => f.startsWith('statusline-exporter.sh.'));
|
||||
expect(siblings).toEqual([]);
|
||||
});
|
||||
|
||||
it('never overrides a real, hand-authored statusLine', async () => {
|
||||
const claudeDir = join(testDir, '.claude');
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/** @fileoverview Settings → Terminal & Input → Key tester, driven with real keystrokes in Chromium. */
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3194;
|
||||
|
||||
describe('Key tester in a real browser', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(PORT, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage();
|
||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
await page.evaluate(() => (window as any).app.openAppSettings());
|
||||
await page.focus('#keyTesterInput');
|
||||
}, 90000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
}, 60000);
|
||||
|
||||
const log = () => page.evaluate(() => document.getElementById('keyTesterLog')!.textContent ?? '');
|
||||
|
||||
it('shows keydown, keypress and keyup for Shift+Enter, with the modifier and charCode', async () => {
|
||||
await page.keyboard.press('Shift+Enter');
|
||||
const text = await log();
|
||||
expect(text).toMatch(/keydown\s+key="Enter" code=Enter mods=shift/);
|
||||
// The keypress is the event that used to leak a bare \r to the PTY.
|
||||
expect(text).toMatch(/keypress\s+key="Enter" code=Enter mods=shift charCode=13/);
|
||||
expect(text).toMatch(/keyup\s+key="Enter" code=Enter mods=shift/);
|
||||
});
|
||||
|
||||
it('shows Ctrl+Enter without a keypress, as xterm would never see one for Ctrl', async () => {
|
||||
await page.evaluate(() => (document.getElementById('keyTesterLog')!.textContent = ''));
|
||||
await page.keyboard.press('Control+Enter');
|
||||
const text = await log();
|
||||
expect(text).toMatch(/keydown\s+key="Enter" code=Enter mods=ctrl/);
|
||||
expect(text).toMatch(/keyup/);
|
||||
// Chromium emits no keypress for a Ctrl chord, which is why only Shift+Enter ever leaked a \r.
|
||||
expect(text).not.toMatch(/keypress/);
|
||||
});
|
||||
|
||||
it('lets no app shortcut fire for keys pressed in the field (Ctrl+W, Ctrl+L, Escape, Alt+1, Ctrl+K)', async () => {
|
||||
// The shortcut dispatcher is a capture-phase document listener, so without a guard it ran before
|
||||
// the field's own handler: Ctrl+W killed the active session, Ctrl+L cleared the terminal and
|
||||
// Escape closed Settings, while this row says nothing is sent to a session.
|
||||
await page.evaluate(() => {
|
||||
const app = (window as any).app;
|
||||
const calls: string[] = [];
|
||||
(window as any).__calls = calls;
|
||||
for (const name of ['killActiveSession', 'clearTerminal', 'openCommandPalette', 'closeAllPanels']) {
|
||||
app[name] = (...args: unknown[]) => void calls.push(name + args.length);
|
||||
}
|
||||
});
|
||||
await page.focus('#keyTesterInput');
|
||||
// [chord, what the tester must report for it]; checked one at a time because the log keeps 14 lines.
|
||||
const chords: [string, RegExp][] = [
|
||||
['Control+W', /key="w" code=KeyW mods=ctrl/i],
|
||||
['Control+L', /key="l" code=KeyL mods=ctrl/i],
|
||||
['Escape', /key="Escape" code=Escape/],
|
||||
['Alt+1', /code=Digit1 mods=alt/],
|
||||
['Control+K', /key="k" code=KeyK mods=ctrl/i],
|
||||
];
|
||||
for (const [chord, seen] of chords) {
|
||||
await page.evaluate(() => (document.getElementById('keyTesterLog')!.textContent = ''));
|
||||
await page.keyboard.press(chord);
|
||||
expect(await log(), chord).toMatch(seen);
|
||||
expect(await page.evaluate(() => (window as any).__calls), chord).toEqual([]);
|
||||
}
|
||||
expect(await page.evaluate(() => document.getElementById('appSettingsModal')!.classList.contains('active'))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('still lets the shortcut fire anywhere else (the guard is scoped to data-raw-keys)', async () => {
|
||||
await page.evaluate(() => {
|
||||
(window as any).__calls.length = 0;
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
});
|
||||
await page.keyboard.press('Escape');
|
||||
expect(await page.evaluate(() => (window as any).__calls)).toContain('closeAllPanels0');
|
||||
});
|
||||
|
||||
it('keeps only the last 14 lines and never types into the field', async () => {
|
||||
// The test above blurred the field, so focus it again: without this the presses land on
|
||||
// <body>, the log keeps whatever the earlier tests left, and the cap is never exercised.
|
||||
await page.focus('#keyTesterInput');
|
||||
expect(await page.evaluate(() => document.activeElement?.id)).toBe('keyTesterInput');
|
||||
await page.evaluate(() => (document.getElementById('keyTesterLog')!.textContent = ''));
|
||||
const lines = async () => (await log()).split('\n');
|
||||
|
||||
// A printable key fires keydown, keypress and keyup, so 2 presses are 6 lines: under the cap
|
||||
// the log accumulates rather than showing only the latest event.
|
||||
for (let i = 0; i < 2; i++) await page.keyboard.press('a');
|
||||
expect(await lines()).toHaveLength(6);
|
||||
|
||||
// A different key, so eviction is visible: 4 x 3 = 12 more lines makes 18, capped to 14. The
|
||||
// oldest 4 go (all of the first 'a' press and the second one's keydown), the newest stay in order.
|
||||
for (let i = 0; i < 4; i++) await page.keyboard.press('b');
|
||||
const capped = await lines();
|
||||
expect(capped).toHaveLength(14);
|
||||
expect(capped.filter((l) => l.includes('key="a"'))).toHaveLength(2);
|
||||
expect(capped.filter((l) => l.includes('key="b"'))).toHaveLength(12);
|
||||
expect(capped[0]).toMatch(/^keypress\s+key="a" code=KeyA mods=none charCode=97$/);
|
||||
expect(capped[13]).toMatch(/^keyup\s+key="b" code=KeyB mods=none$/);
|
||||
|
||||
// Readonly: none of those presses typed anything into the field itself.
|
||||
expect(await page.inputValue('#keyTesterInput')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment node
|
||||
// The registry half of MCP sync: which CLIs declare an MCP config file, and that the schema
|
||||
// guards the path (sync writes to it) so a user clis.json cannot aim a write outside $HOME.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CliEntrySchema } from '../src/config/cli-registry/schema.js';
|
||||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||
import type { CliEntry } from '../src/config/cli-registry/types.js';
|
||||
|
||||
const claude = () => structuredClone(STOCK_CLIS.find((e) => (e.id as string) === 'claude')!) as CliEntry;
|
||||
|
||||
function withMcp(mcpConfig: unknown) {
|
||||
const e = claude();
|
||||
(e.capabilities as Record<string, unknown>).mcpConfig = mcpConfig;
|
||||
return CliEntrySchema.safeParse(e);
|
||||
}
|
||||
|
||||
describe('capabilities.mcpConfig', () => {
|
||||
it('is declared by exactly the CLIs whose format is verified', () => {
|
||||
const declared = STOCK_CLIS.filter((e) => e.capabilities.mcpConfig).map((e) => e.id as string);
|
||||
expect(declared.sort()).toEqual(['antigravity', 'claude', 'codex', 'gemini', 'opencode']);
|
||||
});
|
||||
|
||||
it('every stock declaration passes the schema, with a distinct file per CLI', () => {
|
||||
for (const e of STOCK_CLIS) expect(CliEntrySchema.safeParse(e).success, e.id as string).toBe(true);
|
||||
const paths = STOCK_CLIS.flatMap((e) => (e.capabilities.mcpConfig ? [e.capabilities.mcpConfig.path] : []));
|
||||
expect(new Set(paths).size).toBe(paths.length);
|
||||
});
|
||||
|
||||
it('accepts a home-relative path with a known format', () => {
|
||||
expect(withMcp({ path: '.tool/mcp.json', format: 'claude-json' }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("declares each CLI's own relocation env var, and none for antigravity (HOME only)", () => {
|
||||
const reloc = Object.fromEntries(
|
||||
STOCK_CLIS.flatMap((e) =>
|
||||
e.capabilities.mcpConfig ? [[e.id as string, e.capabilities.mcpConfig.relocation]] : []
|
||||
)
|
||||
);
|
||||
expect(reloc).toEqual({
|
||||
claude: { envVar: 'CLAUDE_CONFIG_DIR', path: '.claude.json' },
|
||||
opencode: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' },
|
||||
codex: { envVar: 'CODEX_HOME', path: 'config.toml' },
|
||||
gemini: { envVar: 'GEMINI_CLI_HOME', path: '.gemini/settings.json' },
|
||||
antigravity: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a relocation with an env var name and a relative path', () => {
|
||||
const value = { path: '.a/mcp.json', format: 'claude-json', relocation: { envVar: 'A_HOME', path: 'mcp.json' } };
|
||||
expect(withMcp(value).success).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['parent traversal', { path: '../evil.json', format: 'claude-json' }],
|
||||
['nested traversal', { path: '.a/../../evil.json', format: 'claude-json' }],
|
||||
['absolute path', { path: '/etc/cron.d/x', format: 'claude-json' }],
|
||||
['shell metacharacters', { path: '.a;rm -rf', format: 'claude-json' }],
|
||||
['unknown format', { path: '.a/mcp.json', format: 'yaml' }],
|
||||
['extra key', { path: '.a/mcp.json', format: 'claude-json', mode: 'rw' }],
|
||||
[
|
||||
'relocation path traversal',
|
||||
{ path: '.a/mcp.json', format: 'claude-json', relocation: { envVar: 'A_HOME', path: '../x.json' } },
|
||||
],
|
||||
[
|
||||
'relocation absolute path',
|
||||
{ path: '.a/mcp.json', format: 'claude-json', relocation: { envVar: 'A_HOME', path: '/etc/x.json' } },
|
||||
],
|
||||
[
|
||||
'relocation env var that is not a name',
|
||||
{ path: '.a/mcp.json', format: 'claude-json', relocation: { envVar: 'a-home', path: 'x.json' } },
|
||||
],
|
||||
])('rejects %s', (_label, value) => {
|
||||
expect(withMcp(value).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,587 @@
|
||||
// @vitest-environment node
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { parse as parseToml } from 'smol-toml';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { addServers, McpSyncBusyError, parseServers, syncMcpServers, type McpSyncTarget } from '../src/mcp-sync.js';
|
||||
|
||||
const TARGETS: McpSyncTarget[] = [
|
||||
{ id: 'claude', label: 'Claude', path: '.claude.json', format: 'claude-json', installed: true },
|
||||
{ id: 'gemini', label: 'Gemini', path: '.gemini/settings.json', format: 'gemini-json', installed: true },
|
||||
{ id: 'codex', label: 'Codex', path: '.codex/config.toml', format: 'codex-toml', installed: true },
|
||||
{
|
||||
id: 'antigravity',
|
||||
label: 'Antigravity',
|
||||
path: '.gemini/config/mcp_config.json',
|
||||
format: 'antigravity-json',
|
||||
installed: true,
|
||||
},
|
||||
{
|
||||
id: 'opencode',
|
||||
label: 'OpenCode',
|
||||
path: '.config/opencode/opencode.json',
|
||||
format: 'opencode-json',
|
||||
installed: true,
|
||||
},
|
||||
];
|
||||
|
||||
let home: string;
|
||||
const put = (rel: string, text: string) => {
|
||||
const file = join(home, rel);
|
||||
mkdirSync(join(file, '..'), { recursive: true });
|
||||
writeFileSync(file, text);
|
||||
};
|
||||
const get = (rel: string) => readFileSync(join(home, rel), 'utf8');
|
||||
const target = (id: string, patch: Partial<McpSyncTarget> = {}) => ({
|
||||
...TARGETS.find((t) => t.id === id)!,
|
||||
...patch,
|
||||
});
|
||||
const only = (...ids: string[]) => TARGETS.filter((t) => ids.includes(t.id));
|
||||
const result = (r: Awaited<ReturnType<typeof syncMcpServers>>, id: string) => r.targets.find((t) => t.id === id)!;
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'mcp-sync-'));
|
||||
});
|
||||
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
||||
|
||||
describe('dialect parsing', () => {
|
||||
it('reads codex TOML tables, inline tables and multi-line arrays', () => {
|
||||
const servers = parseServers(
|
||||
'codex-toml',
|
||||
[
|
||||
'model = "gpt-5"',
|
||||
'',
|
||||
'[mcp_servers.fs]',
|
||||
'command = "npx"',
|
||||
'args = [',
|
||||
' "-y", # comment',
|
||||
' "@mcp/fs",',
|
||||
']',
|
||||
'env = { TOKEN = "abc" }',
|
||||
'',
|
||||
'[mcp_servers."a.b".env]',
|
||||
'K = "v"',
|
||||
'',
|
||||
'[mcp_servers."a.b"]',
|
||||
'command = "x"',
|
||||
'',
|
||||
'[mcp_servers.web]',
|
||||
'url = "https://x.test/mcp"',
|
||||
'[mcp_servers.web.http_headers]',
|
||||
'Authorization = "Bearer t"',
|
||||
].join('\n')
|
||||
);
|
||||
expect(servers.fs).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', '@mcp/fs'], env: { TOKEN: 'abc' } });
|
||||
expect(servers['a.b']).toEqual({ transport: 'stdio', command: 'x', env: { K: 'v' } });
|
||||
expect(servers.web).toEqual({
|
||||
transport: 'http',
|
||||
url: 'https://x.test/mcp',
|
||||
headers: { Authorization: 'Bearer t' },
|
||||
});
|
||||
});
|
||||
|
||||
it('reads CRLF codex files (the old offset math saw no servers at all)', () => {
|
||||
const servers = parseServers('codex-toml', '[mcp_servers.fs]\r\ncommand = "npx"\r\nargs = ["-y"]\r\n');
|
||||
expect(servers.fs).toEqual({ transport: 'stdio', command: 'npx', args: ['-y'] });
|
||||
});
|
||||
|
||||
it('reads gemini url (sse) vs httpUrl / type http, and opencode local/remote', () => {
|
||||
const g = parseServers(
|
||||
'gemini-json',
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
a: { url: 'https://a' },
|
||||
b: { httpUrl: 'https://b' },
|
||||
c: { command: 'c', args: ['1'] },
|
||||
d: { url: 'https://d', type: 'http' },
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(g.a.transport).toBe('sse');
|
||||
expect(g.b.transport).toBe('http');
|
||||
expect(g.c).toEqual({ transport: 'stdio', command: 'c', args: ['1'] });
|
||||
expect(g.d.transport).toBe('http');
|
||||
const o = parseServers(
|
||||
'opencode-json',
|
||||
JSON.stringify({
|
||||
mcp: {
|
||||
l: { type: 'local', command: ['npx', '-y', 'x'], environment: { A: '1' } },
|
||||
r: { type: 'remote', url: 'https://r' },
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(o.l).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', 'x'], env: { A: '1' } });
|
||||
expect(o.r).toEqual({ transport: 'http', url: 'https://r' });
|
||||
});
|
||||
|
||||
it('throws on unparseable files so they are never written', () => {
|
||||
expect(() => parseServers('opencode-json', '{ // jsonc\n}')).toThrow();
|
||||
expect(() => parseServers('codex-toml', '[mcp_servers.a]\ncommand="x"\n[mcp_servers.a]\ncommand="y"\n')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('real CLI output (captured from `agy`/`gemini`/`codex mcp add`)', () => {
|
||||
it('reads and writes the antigravity dialect', () => {
|
||||
const real = JSON.stringify({
|
||||
mcpServers: {
|
||||
fs: { args: ['-y', '@mcp/fs'], command: 'npx', disabled: false, env: { K: 'v' } },
|
||||
web: { disabled: false, headers: { Authorization: 'Bearer T' }, serverUrl: 'https://x.test/mcp' },
|
||||
},
|
||||
});
|
||||
const servers = parseServers('antigravity-json', real);
|
||||
expect(servers.fs).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', '@mcp/fs'], env: { K: 'v' } });
|
||||
expect(servers.web).toEqual({
|
||||
transport: 'http',
|
||||
url: 'https://x.test/mcp',
|
||||
headers: { Authorization: 'Bearer T' },
|
||||
});
|
||||
const out = JSON.parse(
|
||||
addServers('antigravity-json', null, { ...servers, s: { transport: 'sse', url: 'https://s' } })
|
||||
);
|
||||
expect(out.mcpServers.web.serverUrl).toBe('https://x.test/mcp');
|
||||
expect(out.mcpServers.fs.disabled).toBe(false);
|
||||
expect(out.mcpServers.s).toBeUndefined();
|
||||
});
|
||||
|
||||
it('writes gemini http/sse as url + type, as `gemini mcp add` does', () => {
|
||||
const out = JSON.parse(
|
||||
addServers('gemini-json', null, {
|
||||
web: { transport: 'http', url: 'https://x.test/mcp', headers: { A: 'b' } },
|
||||
s: { transport: 'sse', url: 'https://x.test/sse' },
|
||||
})
|
||||
);
|
||||
expect(out.mcpServers.web).toEqual({ url: 'https://x.test/mcp', type: 'http', headers: { A: 'b' } });
|
||||
expect(out.mcpServers.s).toEqual({ url: 'https://x.test/sse', type: 'sse' });
|
||||
expect(parseServers('gemini-json', JSON.stringify(out)).web.transport).toBe('http');
|
||||
});
|
||||
|
||||
it('reads codex output as written by `codex mcp add`', () => {
|
||||
const real =
|
||||
'[mcp_servers.fs]\ncommand = "npx"\nargs = ["-y", "@mcp/fs"]\n\n[mcp_servers.fs.env]\nK = "v"\n\n[mcp_servers.web]\nurl = "https://x.test/mcp"\n';
|
||||
const servers = parseServers('codex-toml', real);
|
||||
expect(servers.fs).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', '@mcp/fs'], env: { K: 'v' } });
|
||||
expect(servers.web).toEqual({ transport: 'http', url: 'https://x.test/mcp' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('hostile config files', () => {
|
||||
it('never lets a server name or a sub-table key reach Object.prototype', () => {
|
||||
const toml = parseServers(
|
||||
'codex-toml',
|
||||
[
|
||||
'[mcp_servers.__proto__]',
|
||||
'command = "x"',
|
||||
'polluted = "yes"',
|
||||
'[mcp_servers.fs.__proto__]',
|
||||
'polluted = "yes"',
|
||||
'[mcp_servers.fs]',
|
||||
'command = "y"',
|
||||
'[mcp_servers.toString]',
|
||||
'command = "t"',
|
||||
'call = "x"',
|
||||
].join('\n')
|
||||
);
|
||||
expect(Object.keys(toml).sort()).toEqual(['fs', 'toString']);
|
||||
const json = parseServers(
|
||||
'claude-json',
|
||||
'{"mcpServers":{"__proto__":{"command":"x"},"constructor":{"command":"x"},"hasOwnProperty":{"command":"h"},"ok":{"command":"y"}}}'
|
||||
);
|
||||
expect(Object.keys(json).sort()).toEqual(['hasOwnProperty', 'ok']);
|
||||
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
||||
expect(({} as Record<string, unknown>).command).toBeUndefined();
|
||||
expect(typeof Object.prototype.toString.call).toBe('function');
|
||||
});
|
||||
|
||||
it('treats servers named like Object.prototype members as ordinary names across CLIs', async () => {
|
||||
put(
|
||||
'.claude.json',
|
||||
JSON.stringify({ mcpServers: { toString: { command: 'a' }, hasOwnProperty: { command: 'b' } } })
|
||||
);
|
||||
put('.gemini/settings.json', JSON.stringify({ mcpServers: {} }));
|
||||
const r = await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
|
||||
expect(r.conflicts).toEqual([]);
|
||||
expect(result(r, 'gemini').added.sort()).toEqual(['hasOwnProperty', 'toString']);
|
||||
expect(Object.keys(JSON.parse(get('.gemini/settings.json')).mcpServers).sort()).toEqual([
|
||||
'hasOwnProperty',
|
||||
'toString',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects a non-object server table instead of overwriting it', () => {
|
||||
expect(() => parseServers('claude-json', '{"mcpServers":[]}')).toThrow();
|
||||
expect(() => parseServers('claude-json', '[]')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addServers', () => {
|
||||
it('preserves other keys and existing servers', () => {
|
||||
const out = JSON.parse(
|
||||
addServers('claude-json', JSON.stringify({ theme: 'dark', mcpServers: { keep: { command: 'k' } } }), {
|
||||
keep: { transport: 'stdio', command: 'OVERWRITE' },
|
||||
n: { transport: 'stdio', command: 'n' },
|
||||
})
|
||||
);
|
||||
expect(out.theme).toBe('dark');
|
||||
expect(out.mcpServers.keep).toEqual({ command: 'k' });
|
||||
expect(out.mcpServers.n.command).toBe('n');
|
||||
});
|
||||
|
||||
it('appends codex tables without touching the rest, and quotes odd names', () => {
|
||||
const toml = addServers('codex-toml', 'model = "x"\n', {
|
||||
'we ird': { transport: 'stdio', command: 'c', args: ['a"b'], env: { K: 'v' } },
|
||||
});
|
||||
expect(toml.startsWith('model = "x"\n')).toBe(true);
|
||||
expect(parseServers('codex-toml', toml)['we ird']).toEqual({
|
||||
transport: 'stdio',
|
||||
command: 'c',
|
||||
args: ['a"b'],
|
||||
env: { K: 'v' },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['CRLF line endings', '[mcp_servers.fs]\r\ncommand = "npx"\r\n'],
|
||||
['an [mcp_servers] table with inline tables', '[mcp_servers]\nfs = { command = "npx" }\n'],
|
||||
['a table with neither command nor url', '[mcp_servers.fs]\nstartup_timeout_sec = 30\n'],
|
||||
])('never appends a second [mcp_servers.fs] to a codex file with %s', (_label, existing) => {
|
||||
const out = addServers('codex-toml', existing, {
|
||||
fs: { transport: 'stdio', command: 'other' },
|
||||
extra: { transport: 'stdio', command: 'e' },
|
||||
});
|
||||
// Still valid TOML (a duplicate header would throw), fs untouched, extra added.
|
||||
const doc = parseToml(out) as { mcp_servers: Record<string, Record<string, unknown>> };
|
||||
expect(Object.keys(doc.mcp_servers).sort()).toEqual(['extra', 'fs']);
|
||||
expect(doc.mcp_servers.fs.command === 'other').toBe(false);
|
||||
expect(out.startsWith(existing)).toBe(true);
|
||||
if (existing.includes('\r\n')) expect(out.replace(/\r\n/g, '')).not.toContain('\n');
|
||||
});
|
||||
|
||||
it('refuses to write when the result would not read back as intended', () => {
|
||||
// A name TOML cannot carry as a bare key still round-trips (quoted); a duplicate cannot.
|
||||
expect(() => addServers('codex-toml', '[mcp_servers.a]\ncommand="x"\n[mcp_servers.a]\n', {})).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabled servers are not propagated', () => {
|
||||
it('codex enabled=false, opencode enabled:false and antigravity disabled:true stay where they are', async () => {
|
||||
put('.codex/config.toml', '[mcp_servers.off_codex]\ncommand = "a"\nenabled = false\n');
|
||||
put(
|
||||
'.config/opencode/opencode.json',
|
||||
JSON.stringify({ mcp: { off_oc: { type: 'local', command: ['b'], enabled: false } } })
|
||||
);
|
||||
put(
|
||||
'.gemini/config/mcp_config.json',
|
||||
JSON.stringify({ mcpServers: { off_agy: { command: 'c', disabled: true } } })
|
||||
);
|
||||
put('.claude.json', JSON.stringify({ mcpServers: { live: { type: 'stdio', command: 'l' } } }));
|
||||
mkdirSync(join(home, '.gemini'), { recursive: true });
|
||||
put('.gemini/settings.json', '{}');
|
||||
const r = await syncMcpServers(TARGETS, { apply: true, home });
|
||||
expect(r.disabled).toEqual(['off_agy', 'off_codex', 'off_oc']);
|
||||
expect(Object.keys(JSON.parse(get('.claude.json')).mcpServers)).toEqual(['live']);
|
||||
expect(Object.keys(JSON.parse(get('.gemini/settings.json')).mcpServers)).toEqual(['live']);
|
||||
// ...and each CLI still gets the live one.
|
||||
expect(get('.codex/config.toml')).toContain('[mcp_servers.live]');
|
||||
expect(get('.codex/config.toml')).not.toContain('off_oc');
|
||||
// The switched-off entry itself is left as it was (still disabled).
|
||||
expect(get('.codex/config.toml')).toContain('enabled = false');
|
||||
expect(JSON.parse(get('.config/opencode/opencode.json')).mcp.off_oc.enabled).toBe(false);
|
||||
expect(JSON.parse(get('.gemini/config/mcp_config.json')).mcpServers.off_agy.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('a name switched off in one CLI and live in another is still synced from the live one', async () => {
|
||||
put('.codex/config.toml', '[mcp_servers.fs]\ncommand = "a"\nenabled = false\n');
|
||||
put('.claude.json', JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'a' } } }));
|
||||
put('.gemini/settings.json', '{}');
|
||||
const r = await syncMcpServers(only('claude', 'codex', 'gemini'), { apply: true, home });
|
||||
expect(r.disabled).toEqual([]);
|
||||
expect(result(r, 'gemini').added).toEqual(['fs']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncMcpServers', () => {
|
||||
const claudeFile = JSON.stringify({
|
||||
numStartups: 3,
|
||||
mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'], env: { T: 's3cret' } } },
|
||||
});
|
||||
const setUpAll = () => {
|
||||
for (const d of ['.gemini/config', '.codex', '.config/opencode']) mkdirSync(join(home, d), { recursive: true });
|
||||
put('.gemini/settings.json', '{}');
|
||||
put('.codex/config.toml', '');
|
||||
put('.config/opencode/opencode.json', '{}');
|
||||
put('.gemini/config/mcp_config.json', '{}');
|
||||
};
|
||||
|
||||
it('previews without writing and never leaks env values', async () => {
|
||||
setUpAll();
|
||||
put('.claude.json', claudeFile);
|
||||
const before = get('.gemini/settings.json');
|
||||
const r = await syncMcpServers(TARGETS, { apply: false, home });
|
||||
expect(r.applied).toBe(false);
|
||||
expect(result(r, 'gemini').added).toEqual(['fs']);
|
||||
expect(get('.gemini/settings.json')).toBe(before);
|
||||
expect(JSON.stringify(r)).not.toContain('s3cret');
|
||||
});
|
||||
|
||||
it('adds missing servers to every other CLI, keeps a backup, is idempotent', async () => {
|
||||
setUpAll();
|
||||
put('.claude.json', claudeFile);
|
||||
put('.codex/config.toml', 'model = "gpt-5"\n[mcp_servers.web]\nurl = "https://w"\n');
|
||||
const r = await syncMcpServers(TARGETS, { apply: true, home });
|
||||
expect(result(r, 'claude').added).toEqual(['web']);
|
||||
expect(result(r, 'codex').added).toEqual(['fs']);
|
||||
expect(JSON.parse(get('.claude.json')).numStartups).toBe(3);
|
||||
expect(Object.keys(JSON.parse(get('.gemini/settings.json')).mcpServers).sort()).toEqual(['fs', 'web']);
|
||||
expect(JSON.parse(get('.config/opencode/opencode.json')).mcp.fs.command).toEqual(['npx', '-y', 'fs']);
|
||||
expect(get('.codex/config.toml')).toContain('model = "gpt-5"');
|
||||
expect(existsSync(join(home, '.claude.json.codeman-bak'))).toBe(true);
|
||||
|
||||
const again = await syncMcpServers(TARGETS, { apply: true, home });
|
||||
expect(again.targets.every((t) => t.added.length === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports conflicts without overwriting, skips what a dialect cannot express, leaves unreadable files alone', async () => {
|
||||
setUpAll();
|
||||
put(
|
||||
'.claude.json',
|
||||
JSON.stringify({ mcpServers: { x: { command: 'one' }, sse: { type: 'sse', url: 'https://s' } } })
|
||||
);
|
||||
put('.gemini/settings.json', JSON.stringify({ mcpServers: { x: { command: 'two' } } }));
|
||||
const broken = '{ // jsonc\n "mcp": {} }';
|
||||
put('.config/opencode/opencode.json', broken);
|
||||
const r = await syncMcpServers(TARGETS, { apply: true, home });
|
||||
expect(r.conflicts).toEqual(['x']);
|
||||
expect(JSON.parse(get('.gemini/settings.json')).mcpServers.x.command).toBe('two');
|
||||
expect(result(r, 'codex').skipped).toEqual(['sse']);
|
||||
expect(result(r, 'opencode').status).toBe('unreadable');
|
||||
expect(get('.config/opencode/opencode.json')).toBe(broken);
|
||||
});
|
||||
|
||||
it('passes the unsupported list through to the result', async () => {
|
||||
const r = await syncMcpServers(TARGETS, { apply: false, home }, ['Pi']);
|
||||
expect(r.unsupported).toEqual(['Pi']);
|
||||
});
|
||||
|
||||
describe('only CLIs that are installed or already have a config file take part', () => {
|
||||
it('never creates config for a CLI that is neither installed nor configured', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
const notInstalled = TARGETS.map((t) => (t.id === 'claude' ? t : { ...t, installed: false }));
|
||||
const r = await syncMcpServers(notInstalled, { apply: true, home });
|
||||
for (const id of ['gemini', 'codex', 'antigravity', 'opencode']) expect(result(r, id).status).toBe('absent');
|
||||
expect(existsSync(join(home, '.codex'))).toBe(false);
|
||||
expect(existsSync(join(home, '.gemini'))).toBe(false);
|
||||
expect(existsSync(join(home, '.config'))).toBe(false);
|
||||
});
|
||||
|
||||
it('a CLI that is not detected as installed still takes part if its config file exists', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
put('.gemini/settings.json', '{}');
|
||||
const r = await syncMcpServers([target('claude'), target('gemini', { installed: false })], { apply: true, home });
|
||||
expect(result(r, 'gemini').added).toEqual(['fs']);
|
||||
});
|
||||
|
||||
it('an installed CLI with no config yet gets one created', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
const r = await syncMcpServers([target('claude'), target('codex')], { apply: true, home });
|
||||
expect(result(r, 'codex').added).toEqual(['fs']);
|
||||
expect(get('.codex/config.toml')).toContain('[mcp_servers.fs]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('file safety', () => {
|
||||
it('leaves a file that receives env values or headers readable by its owner only', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
put('.gemini/settings.json', '{}');
|
||||
chmodSync(join(home, '.gemini/settings.json'), 0o664);
|
||||
await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
|
||||
expect(statSync(join(home, '.gemini/settings.json')).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('keeps the existing mode when nothing secret is copied', async () => {
|
||||
put('.claude.json', JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx' } } }));
|
||||
put('.gemini/settings.json', '{}');
|
||||
chmodSync(join(home, '.gemini/settings.json'), 0o664);
|
||||
await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
|
||||
expect(statSync(join(home, '.gemini/settings.json')).mode & 0o777).toBe(0o664);
|
||||
});
|
||||
|
||||
it('writes through a symlinked config instead of replacing the link', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
mkdirSync(join(home, 'dotfiles'), { recursive: true });
|
||||
writeFileSync(join(home, 'dotfiles/gemini-settings.json'), '{}');
|
||||
mkdirSync(join(home, '.gemini'), { recursive: true });
|
||||
symlinkSync(join(home, 'dotfiles/gemini-settings.json'), join(home, '.gemini/settings.json'));
|
||||
await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
|
||||
expect(lstatSync(join(home, '.gemini/settings.json')).isSymbolicLink()).toBe(true);
|
||||
expect(JSON.parse(readFileSync(join(home, 'dotfiles/gemini-settings.json'), 'utf8')).mcpServers.fs.command).toBe(
|
||||
'npx'
|
||||
);
|
||||
expect(existsSync(join(home, 'dotfiles/gemini-settings.json.codeman-bak'))).toBe(true);
|
||||
});
|
||||
|
||||
it('reports a dangling symlink as failed and writes nothing', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
mkdirSync(join(home, '.gemini'), { recursive: true });
|
||||
symlinkSync(join(home, 'nowhere.json'), join(home, '.gemini/settings.json'));
|
||||
const r = await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
|
||||
expect(result(r, 'gemini').status).toBe('failed');
|
||||
expect(existsSync(join(home, 'nowhere.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a second apply while one is running, and leaves no temp files behind', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
put('.gemini/settings.json', '{}');
|
||||
const first = syncMcpServers(only('claude', 'gemini'), { apply: true, home });
|
||||
await expect(syncMcpServers(only('claude', 'gemini'), { apply: true, home })).rejects.toBeInstanceOf(
|
||||
McpSyncBusyError
|
||||
);
|
||||
await first;
|
||||
// A preview is read-only and is never refused.
|
||||
await expect(syncMcpServers(only('claude', 'gemini'), { apply: false, home })).resolves.toBeDefined();
|
||||
// ...and the lock is released afterwards.
|
||||
await expect(syncMcpServers(only('claude', 'gemini'), { apply: true, home })).resolves.toBeDefined();
|
||||
const leftovers = readdirSync(join(home, '.gemini')).filter((f) => f.includes('codeman-tmp'));
|
||||
expect(leftovers).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error messages never quote the config file (it holds env values and headers)', () => {
|
||||
const claudeWithSecret = JSON.stringify({
|
||||
mcpServers: { fs: { type: 'stdio', command: 'npx', env: { TOKEN: 'sk-COPIED-SECRET' } } },
|
||||
});
|
||||
|
||||
it('reports an unparseable TOML file by line and column only', async () => {
|
||||
put('.claude.json', claudeWithSecret);
|
||||
put(
|
||||
'.codex/config.toml',
|
||||
'model = "gpt-5"\n[mcp_servers.linear]\nenv = { LINEAR_API_KEY = "lin_SECRET_abc" broken }\n'
|
||||
);
|
||||
const r = await syncMcpServers(only('claude', 'codex'), { apply: true, home });
|
||||
const codex = result(r, 'codex');
|
||||
expect(codex.status).toBe('unreadable');
|
||||
expect(codex.error).toMatch(/^not valid TOML \(line 3, column \d+\)$/);
|
||||
expect(JSON.stringify(r)).not.toContain('lin_SECRET_abc');
|
||||
expect(JSON.stringify(r)).not.toContain('LINEAR_API_KEY');
|
||||
});
|
||||
|
||||
it('reports an unparseable JSON file by position, or by category when V8 quotes source instead', async () => {
|
||||
put('.claude.json', claudeWithSecret);
|
||||
// V8: `Unexpected token 's', ..."TOKEN":sk-GEMINI-SECRET}"... is not valid JSON` (no position).
|
||||
put('.gemini/settings.json', '{"mcpServers":{"g":{"command":"x","env":{"TOKEN":sk-GEMINI-SECRET}}}}');
|
||||
// V8: `Expected ',' or '}' after property value in JSON at position N (line 2 column M)`.
|
||||
put('.gemini/config/mcp_config.json', '{\n "mcpServers": {"a": {"env": {"K": "sk-AGY-SECRET" "x"}}}\n}');
|
||||
const r = await syncMcpServers(only('claude', 'gemini', 'antigravity'), { apply: false, home });
|
||||
expect(result(r, 'gemini').status).toBe('unreadable');
|
||||
expect(result(r, 'gemini').error).toBe('not valid JSON');
|
||||
expect(result(r, 'antigravity').error).toMatch(/^not valid JSON \(line 2, column \d+\)$/);
|
||||
const body = JSON.stringify(r);
|
||||
for (const secret of ['sk-GEMINI-SECRET', 'sk-AGY-SECRET', 'TOKEN']) expect(body).not.toContain(secret);
|
||||
});
|
||||
|
||||
it('a write refused at the re-parse quotes neither the file nor the copied server', async () => {
|
||||
put('.claude.json', claudeWithSecret);
|
||||
// An inline top-level table parses, but appending `[mcp_servers.fs]` to it does not.
|
||||
const inline = 'mcp_servers = { a = { command = "x", env = { K = "sk-FILE-SECRET" } } }\n';
|
||||
put('.codex/config.toml', inline);
|
||||
const r = await syncMcpServers(only('claude', 'codex'), { apply: true, home });
|
||||
const codex = result(r, 'codex');
|
||||
expect(codex.status).toBe('failed');
|
||||
expect(codex.error).toMatch(/^not valid TOML \(line \d+, column \d+\)$/);
|
||||
expect(get('.codex/config.toml')).toBe(inline);
|
||||
const body = JSON.stringify(r);
|
||||
expect(body).not.toContain('sk-FILE-SECRET');
|
||||
expect(body).not.toContain('sk-COPIED-SECRET');
|
||||
});
|
||||
});
|
||||
|
||||
describe('relocated config dirs (the CLI reads its file somewhere else)', () => {
|
||||
const claudeFile = JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'] } } });
|
||||
const CODEX_RELOC = { relocation: { envVar: 'CODEX_HOME', path: 'config.toml' } };
|
||||
const CLAUDE_RELOC = { relocation: { envVar: 'CLAUDE_CONFIG_DIR', path: '.claude.json' } };
|
||||
const OPENCODE_RELOC = { relocation: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' } };
|
||||
|
||||
it('writes $CODEX_HOME/config.toml, never the default ~/.codex/config.toml', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
const codexHome = join(home, 'elsewhere/codex');
|
||||
const r = await syncMcpServers([target('claude'), target('codex', CODEX_RELOC)], {
|
||||
apply: true,
|
||||
home,
|
||||
env: { CODEX_HOME: codexHome },
|
||||
});
|
||||
expect(result(r, 'codex').file).toBe(join(codexHome, 'config.toml'));
|
||||
expect(result(r, 'codex').added).toEqual(['fs']);
|
||||
expect(readFileSync(join(codexHome, 'config.toml'), 'utf8')).toContain('[mcp_servers.fs]');
|
||||
expect(existsSync(join(home, '.codex'))).toBe(false);
|
||||
});
|
||||
|
||||
it('reads the source from $CLAUDE_CONFIG_DIR and writes $XDG_CONFIG_HOME/opencode', async () => {
|
||||
const claudeDir = join(home, 'accounts/work');
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
writeFileSync(join(claudeDir, '.claude.json'), claudeFile);
|
||||
// A default-location file that claude does NOT read under CLAUDE_CONFIG_DIR: its server must not spread.
|
||||
put('.claude.json', JSON.stringify({ mcpServers: { stray: { type: 'stdio', command: 'nope' } } }));
|
||||
const xdg = join(home, 'xdg');
|
||||
const r = await syncMcpServers([target('claude', CLAUDE_RELOC), target('opencode', OPENCODE_RELOC)], {
|
||||
apply: true,
|
||||
home,
|
||||
env: { CLAUDE_CONFIG_DIR: claudeDir, XDG_CONFIG_HOME: xdg },
|
||||
});
|
||||
expect(result(r, 'claude').servers).toEqual(['fs']);
|
||||
expect(Object.keys(JSON.parse(readFileSync(join(xdg, 'opencode/opencode.json'), 'utf8')).mcp)).toEqual(['fs']);
|
||||
expect(existsSync(join(home, '.config'))).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a relative relocation value as skipped and writes nothing anywhere', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
const r = await syncMcpServers([target('claude'), target('codex', CODEX_RELOC)], {
|
||||
apply: true,
|
||||
home,
|
||||
env: { CODEX_HOME: 'relative/codex' },
|
||||
});
|
||||
const codex = result(r, 'codex');
|
||||
expect(codex.status).toBe('skipped');
|
||||
expect(codex.error).toMatch(/CODEX_HOME is set to a relative path/);
|
||||
expect(codex.added).toEqual([]);
|
||||
expect(existsSync(join(home, '.codex'))).toBe(false);
|
||||
expect(existsSync(join(process.cwd(), 'relative'))).toBe(false);
|
||||
});
|
||||
|
||||
it('an empty value means unset, as it does for the CLI', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
const r = await syncMcpServers([target('claude'), target('codex', CODEX_RELOC)], {
|
||||
apply: true,
|
||||
home,
|
||||
env: { CODEX_HOME: '' },
|
||||
});
|
||||
expect(result(r, 'codex').file).toBe(join(home, '.codex/config.toml'));
|
||||
expect(get('.codex/config.toml')).toContain('[mcp_servers.fs]');
|
||||
});
|
||||
|
||||
it("ignores the caller's own env when home is overridden and no env is passed", async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
const saved = process.env.CODEX_HOME;
|
||||
process.env.CODEX_HOME = join(home, 'from-process-env');
|
||||
try {
|
||||
const r = await syncMcpServers([target('claude'), target('codex', CODEX_RELOC)], { apply: true, home });
|
||||
expect(result(r, 'codex').file).toBe(join(home, '.codex/config.toml'));
|
||||
expect(existsSync(join(home, 'from-process-env'))).toBe(false);
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = saved;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,722 @@
|
||||
/**
|
||||
* @fileoverview Wiring tests for the iOS IME preview (mobile-ime-preview.js).
|
||||
*
|
||||
* The controller itself is covered by test/mobile-ime-preview.test.ts. These
|
||||
* pin how terminal-ui.js and the delivery graph consume it: script order,
|
||||
* build registration and CSS, the _init/_destroyMobileImePreview lifecycle,
|
||||
* the onData routing of an IME commit, and the rule that only output accepted
|
||||
* AFTER a commit may clear the committed preview.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const read = (path: string) => readFileSync(resolve(import.meta.dirname, '..', path), 'utf8');
|
||||
const indexSource = read('src/web/public/index.html');
|
||||
const buildSource = read('scripts/build.mjs');
|
||||
const terminalSource = read('src/web/public/terminal-ui.js');
|
||||
const cssSource = read('src/web/public/styles.css');
|
||||
|
||||
type Fn = ReturnType<typeof vi.fn>;
|
||||
type App = Record<string, any>;
|
||||
|
||||
function loadMixin(globals: Record<string, unknown> = {}) {
|
||||
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, unknown> };
|
||||
const windowStub = {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
...(globals.window as object),
|
||||
} as Record<string, unknown>;
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
requestAnimationFrame: vi.fn(),
|
||||
cancelAnimationFrame: vi.fn(),
|
||||
URLSearchParams,
|
||||
location: { search: '' },
|
||||
localStorage: { getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
document: { addEventListener: vi.fn(), createElement: vi.fn() },
|
||||
MobileDetection: { isTouchDevice: () => false },
|
||||
CodemanApp: FakeCodemanApp,
|
||||
_crashDiag: { log: vi.fn() },
|
||||
...globals,
|
||||
window: windowStub,
|
||||
});
|
||||
vm.runInContext(terminalSource, context, { filename: 'terminal-ui.js' });
|
||||
return { mixin: FakeCodemanApp.prototype, context, windowStub };
|
||||
}
|
||||
|
||||
function fakeClassList() {
|
||||
const values = new Set<string>();
|
||||
return {
|
||||
add: vi.fn((value: string) => values.add(value)),
|
||||
remove: vi.fn((value: string) => values.delete(value)),
|
||||
toggle: vi.fn((value: string, force?: boolean) => {
|
||||
const enabled = force === undefined ? !values.has(value) : force;
|
||||
if (enabled) values.add(value);
|
||||
else values.delete(value);
|
||||
return enabled;
|
||||
}),
|
||||
contains: (value: string) => values.has(value),
|
||||
};
|
||||
}
|
||||
|
||||
function createPreviewHarness(
|
||||
options: {
|
||||
eligible?: boolean;
|
||||
createThrows?: boolean;
|
||||
omitGlobal?: boolean;
|
||||
themeForeground?: string;
|
||||
themeBackground?: string;
|
||||
themeGetterThrows?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
const compositionView = {
|
||||
style: {
|
||||
fontFamily: '"Fira Code"',
|
||||
fontSize: '10px',
|
||||
fontWeight: '500',
|
||||
fontStyle: 'italic',
|
||||
lineHeight: '12px',
|
||||
height: '12px',
|
||||
color: 'rgb(255, 255, 255)',
|
||||
},
|
||||
};
|
||||
const helpers = {
|
||||
classList: fakeClassList(),
|
||||
children: [] as Array<Record<string, unknown>>,
|
||||
querySelector: (selector: string) => (selector === '.composition-view' ? compositionView : null),
|
||||
appendChild(node: Record<string, unknown>) {
|
||||
this.children.push(node);
|
||||
},
|
||||
};
|
||||
const createdControllers: Array<Record<string, any>> = [];
|
||||
const previewNodes: Array<Record<string, any>> = [];
|
||||
const documentStub = {
|
||||
addEventListener: vi.fn(),
|
||||
createElement: vi.fn(() => {
|
||||
const node = {
|
||||
className: '',
|
||||
hidden: false,
|
||||
textContent: '',
|
||||
dataset: {} as Record<string, string>,
|
||||
style: {} as Record<string, string>,
|
||||
attributes: {} as Record<string, string>,
|
||||
setAttribute(name: string, value: string) {
|
||||
this.attributes[name] = value;
|
||||
},
|
||||
remove: vi.fn(),
|
||||
};
|
||||
previewNodes.push(node);
|
||||
return node;
|
||||
}),
|
||||
};
|
||||
const mobileImePreview = options.omitGlobal
|
||||
? undefined
|
||||
: {
|
||||
isIosWebKitTouch: vi.fn(() => options.eligible ?? true),
|
||||
create: vi.fn((callbacks: Record<string, unknown>) => {
|
||||
if (options.createThrows) throw new Error('controller unavailable');
|
||||
const controller = {
|
||||
destroy: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
consumeTerminalData: vi.fn(() => false),
|
||||
completeCommit: vi.fn(),
|
||||
noteAuthoritativeOutput: vi.fn(),
|
||||
callbacks,
|
||||
};
|
||||
createdControllers.push(controller);
|
||||
return controller;
|
||||
}),
|
||||
};
|
||||
const { mixin, windowStub } = loadMixin({
|
||||
document: documentStub,
|
||||
MobileImePreview: mobileImePreview,
|
||||
getComputedStyle: (node: { style: Record<string, string> }) => node.style,
|
||||
});
|
||||
windowStub.codemanCurrentXtermTheme = () => {
|
||||
if (options.themeGetterThrows) throw new Error('theme unavailable');
|
||||
return { foreground: '#334455', background: '#223344' };
|
||||
};
|
||||
const app: App = Object.assign(Object.create(mixin), {
|
||||
terminal: {
|
||||
textarea: {},
|
||||
options: {
|
||||
theme:
|
||||
options.themeForeground || options.themeBackground
|
||||
? { foreground: options.themeForeground, background: options.themeBackground }
|
||||
: undefined,
|
||||
},
|
||||
element: { querySelector: (selector: string) => (selector === '.xterm-helpers' ? helpers : null) },
|
||||
},
|
||||
activeSessionId: 'session-a',
|
||||
});
|
||||
return { app, helpers, compositionView, previewNodes, createdControllers, mobileImePreview, windowStub };
|
||||
}
|
||||
|
||||
describe('mobile IME preview delivery graph', () => {
|
||||
it('loads the controller after xterm and before terminal wiring', () => {
|
||||
const at = indexSource.indexOf('<script defer src="mobile-ime-preview.js"></script>');
|
||||
expect(at).toBeGreaterThan(indexSource.indexOf('vendor/xterm.min.js'));
|
||||
expect(at).toBeLessThan(indexSource.indexOf('<script defer src="terminal-ui.js"></script>'));
|
||||
expect(at).toBeLessThan(indexSource.indexOf('<script defer src="app.js"></script>'));
|
||||
});
|
||||
|
||||
it('is minified and content-hashed by the build', () => {
|
||||
expect(buildSource).toContain("run('minify mobile-ime-preview.js'");
|
||||
expect(buildSource).toMatch(/const HASHABLE = \[[^\]]*'mobile-ime-preview\.js'/);
|
||||
});
|
||||
|
||||
it('scopes preview presentation and native composition suppression to touch ownership', () => {
|
||||
expect(cssSource).toContain('.xterm-helpers .codeman-ime-preview {');
|
||||
expect(cssSource).toContain(".xterm-helpers .codeman-ime-preview[data-phase='provisional'] {");
|
||||
expect(cssSource).toContain('.touch-device .xterm-helpers.codeman-ime-preview-owned .composition-view.active {');
|
||||
const rule = cssSource.slice(cssSource.indexOf('.xterm-helpers .codeman-ime-preview {'));
|
||||
expect(rule.slice(0, rule.indexOf('}'))).toContain('left: var(--xterm-helper-left, 0px)');
|
||||
expect(rule.slice(0, rule.indexOf('}'))).toContain('top: var(--xterm-helper-top, 0px)');
|
||||
});
|
||||
|
||||
it('initializes the preview right after the terminal opens', () => {
|
||||
expect(terminalSource).toMatch(/this\.terminal\.open\(container\);\s*this\._initMobileImePreview\(\);/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mobile IME preview lifecycle', () => {
|
||||
it('fails open when the global is absent or create throws', () => {
|
||||
for (const options of [{ omitGlobal: true }, { createThrows: true }]) {
|
||||
const { app } = createPreviewHarness(options);
|
||||
expect(() => app._initMobileImePreview()).not.toThrow();
|
||||
expect(app._mobileImePreview).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not create a controller on unsupported input platforms', () => {
|
||||
const { app, mobileImePreview, previewNodes } = createPreviewHarness({ eligible: false });
|
||||
app._initMobileImePreview();
|
||||
expect(mobileImePreview?.create).not.toHaveBeenCalled();
|
||||
expect(previewNodes).toHaveLength(0);
|
||||
expect(app._mobileImePreview).toBeNull();
|
||||
});
|
||||
|
||||
it('creates one controller bound to the terminal textarea and one hidden preview node', () => {
|
||||
const { app, helpers, previewNodes, mobileImePreview } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
expect(mobileImePreview?.create).toHaveBeenCalledOnce();
|
||||
expect(mobileImePreview?.create.mock.calls[0][0].textarea).toBe(app.terminal.textarea);
|
||||
// Keydown is observed on the terminal element (an ancestor of the
|
||||
// textarea), so it runs before xterm's own capture listener finalizes.
|
||||
expect(mobileImePreview?.create.mock.calls[0][0].keydownTarget).toBe(app.terminal.element);
|
||||
expect(helpers.children).toEqual([previewNodes[0]]);
|
||||
expect(previewNodes[0]).toMatchObject({ className: 'codeman-ime-preview', hidden: true });
|
||||
expect(previewNodes[0].attributes['aria-hidden']).toBe('true');
|
||||
});
|
||||
|
||||
it('destroys prior ownership on repeated initialization and keeps one active controller', () => {
|
||||
const { app, createdControllers, previewNodes, windowStub } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
const first = createdControllers[0];
|
||||
app._initMobileImePreview();
|
||||
expect(first.destroy).toHaveBeenCalledOnce();
|
||||
expect(previewNodes[0].remove).toHaveBeenCalledOnce();
|
||||
expect(createdControllers).toHaveLength(2);
|
||||
expect(app._mobileImePreview).toBe(createdControllers[1]);
|
||||
// Window listeners are released with the controller that owned them.
|
||||
expect((windowStub.removeEventListener as Fn).mock.calls.map((call) => call[0]).sort()).toEqual([
|
||||
'offline',
|
||||
'pagehide',
|
||||
]);
|
||||
});
|
||||
|
||||
it('destroy releases the controller, the node and the listeners', () => {
|
||||
const { app, createdControllers, previewNodes, windowStub } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
app._destroyMobileImePreview();
|
||||
expect(createdControllers[0].destroy).toHaveBeenCalledOnce();
|
||||
expect(previewNodes[0].remove).toHaveBeenCalledOnce();
|
||||
expect(app._mobileImePreview).toBeNull();
|
||||
expect(windowStub.removeEventListener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('pagehide resets the controller instead of destroying it, so a bfcache restore keeps the preview', () => {
|
||||
const { app, createdControllers, previewNodes, windowStub } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
const pagehide = (windowStub.addEventListener as Fn).mock.calls.find((call) => call[0] === 'pagehide')?.[1];
|
||||
expect(pagehide).toBeTypeOf('function');
|
||||
pagehide();
|
||||
expect(createdControllers[0].reset).toHaveBeenCalledOnce();
|
||||
expect(createdControllers[0].destroy).not.toHaveBeenCalled();
|
||||
expect(previewNodes[0].remove).not.toHaveBeenCalled();
|
||||
expect(app._mobileImePreview).toBe(createdControllers[0]);
|
||||
// A second hide after the page came back from the cache still works.
|
||||
pagehide();
|
||||
expect(createdControllers[0].reset).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('resets the controller exactly once when the active session changes', () => {
|
||||
const { app, createdControllers } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
app.activeSessionId = 'session-b';
|
||||
app.loadAppSettingsFromStorage = () => ({ localEchoEnabled: false });
|
||||
app.sessions = new Map();
|
||||
app._updateLocalEchoState();
|
||||
app._updateLocalEchoState();
|
||||
expect(createdControllers[0].reset).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('renders and clears owned preview state', () => {
|
||||
const { app, helpers, previewNodes, createdControllers } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
const callbacks = createdControllers[0].callbacks;
|
||||
callbacks.render({ text: '你好', phase: 'provisional' });
|
||||
expect(previewNodes[0]).toMatchObject({ textContent: '你好', hidden: false, dataset: { phase: 'provisional' } });
|
||||
expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(true);
|
||||
callbacks.clear();
|
||||
expect(previewNodes[0]).toMatchObject({ textContent: '', hidden: true, dataset: {} });
|
||||
expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(false);
|
||||
});
|
||||
|
||||
// With local echo on, typed text sits in the overlay (z-index 7) and the PTY
|
||||
// cursor that places the span stays at the prompt start, under that text. The
|
||||
// overlay draws the composition instead. Real-xterm proof of the covering:
|
||||
// test/mobile-ime-preview.browser.test.ts.
|
||||
function withOverlay(app: App, visible = true) {
|
||||
const overlay = {
|
||||
composition: '',
|
||||
setComposition: vi.fn(function (this: { composition: string }, text: string) {
|
||||
this.composition = text;
|
||||
}),
|
||||
state: { visible },
|
||||
};
|
||||
Object.assign(app, { _localEchoEnabled: true, _localEchoOverlay: overlay });
|
||||
return overlay;
|
||||
}
|
||||
|
||||
it('routes the preview into the local echo overlay when local echo is on', () => {
|
||||
const { app, helpers, previewNodes, createdControllers } = createPreviewHarness();
|
||||
const overlay = withOverlay(app);
|
||||
app._initMobileImePreview();
|
||||
const callbacks = createdControllers[0].callbacks;
|
||||
callbacks.render({ text: '天気', phase: 'provisional' });
|
||||
expect(overlay.setComposition).toHaveBeenLastCalledWith('天気');
|
||||
expect(previewNodes[0]).toMatchObject({ textContent: '', hidden: true });
|
||||
expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(true);
|
||||
callbacks.clear();
|
||||
expect(overlay.setComposition).toHaveBeenLastCalledWith('');
|
||||
expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the span when the overlay cannot place the composition (no prompt found)', () => {
|
||||
const { app, previewNodes, createdControllers } = createPreviewHarness();
|
||||
const overlay = withOverlay(app, false);
|
||||
app._initMobileImePreview();
|
||||
createdControllers[0].callbacks.render({ text: '天気', phase: 'provisional' });
|
||||
expect(overlay.composition).toBe('');
|
||||
expect(previewNodes[0]).toMatchObject({ textContent: '天気', hidden: false });
|
||||
});
|
||||
|
||||
it('uses the span, not the overlay, when local echo is off or handed back to PTY echo', () => {
|
||||
const off = createPreviewHarness();
|
||||
const offOverlay = withOverlay(off.app);
|
||||
Object.assign(off.app, { _localEchoEnabled: false });
|
||||
off.app._initMobileImePreview();
|
||||
off.createdControllers[0].callbacks.render({ text: 'かな', phase: 'provisional' });
|
||||
expect(offOverlay.setComposition).not.toHaveBeenCalled();
|
||||
expect(off.previewNodes[0]).toMatchObject({ textContent: 'かな', hidden: false });
|
||||
|
||||
const passthrough = createPreviewHarness();
|
||||
const passOverlay = withOverlay(passthrough.app);
|
||||
Object.assign(passthrough.app, { _echoPassthroughSessions: new Set(['session-a']) });
|
||||
passthrough.app._initMobileImePreview();
|
||||
passthrough.createdControllers[0].callbacks.render({ text: 'かな', phase: 'provisional' });
|
||||
expect(passOverlay.setComposition).not.toHaveBeenCalled();
|
||||
expect(passthrough.previewNodes[0]).toMatchObject({ textContent: 'かな', hidden: false });
|
||||
});
|
||||
|
||||
it('uses the terminal foreground and opaque background while mirroring native composition font metrics', () => {
|
||||
const { app, compositionView, previewNodes, createdControllers } = createPreviewHarness({
|
||||
themeForeground: '#1f2328',
|
||||
themeBackground: '#fafafa',
|
||||
});
|
||||
app._initMobileImePreview();
|
||||
createdControllers[0].callbacks.render({ text: '入力', phase: 'provisional' });
|
||||
expect(previewNodes[0].style).toMatchObject({
|
||||
fontFamily: compositionView.style.fontFamily,
|
||||
fontSize: compositionView.style.fontSize,
|
||||
fontWeight: compositionView.style.fontWeight,
|
||||
fontStyle: compositionView.style.fontStyle,
|
||||
lineHeight: compositionView.style.lineHeight,
|
||||
height: compositionView.style.height,
|
||||
color: '#1f2328',
|
||||
backgroundColor: '#fafafa',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the current skin theme when the terminal options carry no theme', () => {
|
||||
const { app, previewNodes, createdControllers } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
createdControllers[0].callbacks.render({ text: '入力', phase: 'provisional' });
|
||||
expect(previewNodes[0].style).toMatchObject({ color: '#334455', backgroundColor: '#223344' });
|
||||
});
|
||||
|
||||
it('keeps rendering with a safe foreground when the theme getter throws', () => {
|
||||
const { app, previewNodes, createdControllers } = createPreviewHarness({ themeGetterThrows: true });
|
||||
app._initMobileImePreview();
|
||||
expect(() => createdControllers[0].callbacks.render({ text: '安全', phase: 'provisional' })).not.toThrow();
|
||||
expect(previewNodes[0].textContent).toBe('安全');
|
||||
expect(previewNodes[0].style.color).toBe('#e0e0e0');
|
||||
expect(previewNodes[0].style.backgroundColor).toBe('#0d0d0d');
|
||||
});
|
||||
|
||||
it.each(['query', 'create', 'append', 'className', 'hidden'] as const)(
|
||||
'removes partial DOM ownership when %s fails',
|
||||
(failure) => {
|
||||
const removed = vi.fn();
|
||||
const owner = fakeClassList();
|
||||
const preview = new Proxy(
|
||||
{ dataset: {}, remove: removed, setAttribute: vi.fn() },
|
||||
{
|
||||
set(target, property, value) {
|
||||
if (property === failure) throw new Error(`${failure} failed`);
|
||||
return Reflect.set(target, property, value);
|
||||
},
|
||||
}
|
||||
);
|
||||
const helpers = {
|
||||
classList: owner,
|
||||
appendChild:
|
||||
failure === 'append'
|
||||
? () => {
|
||||
throw new Error('append failed');
|
||||
}
|
||||
: vi.fn(),
|
||||
};
|
||||
const documentStub = {
|
||||
addEventListener: vi.fn(),
|
||||
createElement:
|
||||
failure === 'create'
|
||||
? () => {
|
||||
throw new Error('create failed');
|
||||
}
|
||||
: () => preview,
|
||||
};
|
||||
const MobileImePreview = { isIosWebKitTouch: () => true, create: vi.fn() };
|
||||
const { mixin } = loadMixin({ document: documentStub, MobileImePreview });
|
||||
const app: App = Object.assign(Object.create(mixin), {
|
||||
terminal: {
|
||||
textarea: {},
|
||||
element: {
|
||||
querySelector:
|
||||
failure === 'query'
|
||||
? () => {
|
||||
throw new Error('query failed');
|
||||
}
|
||||
: () => helpers,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(() => app._initMobileImePreview()).not.toThrow();
|
||||
expect(app._mobileImePreview).toBeNull();
|
||||
expect(MobileImePreview.create).not.toHaveBeenCalled();
|
||||
expect(owner.contains('codeman-ime-preview-owned')).toBe(false);
|
||||
if (!['query', 'create'].includes(failure)) expect(removed).toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Rebuilds the real `handleTerminalData` closure from initTerminal's source,
|
||||
* so these cases exercise the shipped routing rather than a copy of it.
|
||||
*/
|
||||
function loadHandleTerminalData(app: App, sent: string[]) {
|
||||
const marker = 'const handleTerminalData = (data) => {';
|
||||
const start = terminalSource.indexOf(marker);
|
||||
if (start < 0) throw new Error('handleTerminalData definition not found');
|
||||
const bodyStart = start + marker.length;
|
||||
const end = terminalSource.indexOf('\n };', bodyStart);
|
||||
if (end < 0) throw new Error('handleTerminalData boundary not found');
|
||||
const timers: Array<() => void> = [];
|
||||
app._sendInputAsync = (_sessionId: string, data: string) => sent.push(data);
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setTimeout: (callback: () => void) => {
|
||||
timers.push(callback);
|
||||
return timers.length;
|
||||
},
|
||||
clearTimeout: vi.fn(),
|
||||
document: { activeElement: null, getElementById: vi.fn(() => null) },
|
||||
window: {
|
||||
cjkActive: false,
|
||||
CodemanTerminalInput: {
|
||||
BRACKETED_PASTE_START: '\x1b[200~',
|
||||
shouldSuppressTerminalQueryResponse: () => false,
|
||||
isTerminalFocusOrMouseReport: () => false,
|
||||
isComposerNavKey: () => false,
|
||||
},
|
||||
},
|
||||
_crashDiag: { log: vi.fn() },
|
||||
flushInput: () => {
|
||||
app._inputFlushTimeout = null;
|
||||
if (app._pendingInput && app.activeSessionId) {
|
||||
const input = app._pendingInput;
|
||||
app._pendingInput = '';
|
||||
app._sendInputAsync(app.activeSessionId, input);
|
||||
}
|
||||
},
|
||||
});
|
||||
const handler = vm.runInContext(`(function (data) {${terminalSource.slice(bodyStart, end)}\n})`, context) as (
|
||||
this: App,
|
||||
data: string
|
||||
) => void;
|
||||
return { handle: (data: string) => handler.call(app, data), timers };
|
||||
}
|
||||
|
||||
describe('mobile IME commit onData routing', () => {
|
||||
function onDataApp(options: {
|
||||
localEcho: boolean;
|
||||
tagged?: boolean;
|
||||
overlayMissing?: boolean;
|
||||
addThrows?: boolean;
|
||||
appendThrows?: boolean;
|
||||
consumeThrows?: boolean;
|
||||
}) {
|
||||
const { mixin } = loadMixin();
|
||||
const sent: string[] = [];
|
||||
const controller = {
|
||||
consumeTerminalData: options.consumeThrows
|
||||
? vi.fn(() => {
|
||||
throw new Error('consume failed');
|
||||
})
|
||||
: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(options.tagged ?? true)
|
||||
.mockReturnValue(false),
|
||||
completeCommit: vi.fn(),
|
||||
noteAuthoritativeOutput: vi.fn(),
|
||||
};
|
||||
const overlay = {
|
||||
pendingText: '',
|
||||
appendText: vi.fn((data: string) => {
|
||||
if (options.appendThrows) throw new Error('overlay failed');
|
||||
overlay.pendingText += data;
|
||||
}),
|
||||
addChar: vi.fn((data: string) => {
|
||||
if (options.addThrows) throw new Error('overlay failed');
|
||||
overlay.pendingText += data;
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
overlay.pendingText = '';
|
||||
}),
|
||||
suppressBufferDetection: vi.fn(),
|
||||
};
|
||||
const app: App = Object.assign(Object.create(mixin), {
|
||||
activeSessionId: 'session-a',
|
||||
_localEchoEnabled: options.localEcho,
|
||||
_localEchoOverlay: options.overlayMissing ? null : overlay,
|
||||
_echoPassthroughSessions: new Set(),
|
||||
_flushedOffsets: new Map(),
|
||||
_flushedTexts: new Map(),
|
||||
_pendingInput: '',
|
||||
_inputFlushTimeout: null,
|
||||
_lastKeystrokeTime: 0,
|
||||
_terminalOutputSeq: 5,
|
||||
_mobileImePreview: controller,
|
||||
});
|
||||
return { app, controller, overlay, sent, ...loadHandleTerminalData(app, sent) };
|
||||
}
|
||||
|
||||
it('moves a multi-character commit into local echo and submits it only on Enter', () => {
|
||||
const { app, controller, overlay, sent, handle, timers } = onDataApp({ localEcho: true });
|
||||
handle('你好');
|
||||
expect(controller.consumeTerminalData).toHaveBeenCalledOnce();
|
||||
expect(overlay.appendText).toHaveBeenCalledWith('你好');
|
||||
expect(controller.completeCommit).toHaveBeenCalledWith({ predicted: true });
|
||||
expect(app._mobileImeCommitOutputSeq).toBeNull();
|
||||
expect(sent).toEqual([]);
|
||||
|
||||
handle('\r');
|
||||
expect(sent).toEqual(['你好']);
|
||||
timers.shift()?.();
|
||||
expect(sent).toEqual(['你好', '\r']);
|
||||
});
|
||||
|
||||
it('moves a single-character commit into local echo through addChar', () => {
|
||||
const { controller, overlay, sent, handle } = onDataApp({ localEcho: true });
|
||||
handle('界');
|
||||
expect(overlay.addChar).toHaveBeenCalledWith('界');
|
||||
expect(overlay.appendText).not.toHaveBeenCalled();
|
||||
expect(controller.completeCommit).toHaveBeenCalledWith({ predicted: true });
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['appendText throws', { appendThrows: true }, '失敗'],
|
||||
['addChar throws', { addThrows: true }, '字'],
|
||||
])('never sends a commit the overlay may already hold when %s', (_label, extra, text) => {
|
||||
// One code path with typed text: no send-on-throw fallback, which would
|
||||
// double-send if the overlay threw after appending.
|
||||
const { controller, sent, handle } = onDataApp({ localEcho: true, ...extra });
|
||||
expect(() => handle(text)).toThrow('overlay failed');
|
||||
expect(sent).toEqual([]);
|
||||
// Nothing shows the text, so the preview keeps it until its fallback.
|
||||
expect(controller.completeCommit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the preview when the overlay is missing, exactly like typed text', () => {
|
||||
const { controller, sent, handle } = onDataApp({ localEcho: true, overlayMissing: true });
|
||||
expect(() => handle('日本')).not.toThrow();
|
||||
expect(sent).toEqual([]);
|
||||
expect(controller.completeCommit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('completes the commit only after the printable branch has put it in the overlay', () => {
|
||||
const { controller, overlay, handle } = onDataApp({ localEcho: true });
|
||||
controller.completeCommit.mockImplementation(() => {
|
||||
expect(overlay.pendingText).toBe('界');
|
||||
});
|
||||
handle('界');
|
||||
expect(controller.completeCommit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps an untagged paste on the existing local echo path', () => {
|
||||
const { controller, overlay, sent, handle } = onDataApp({ localEcho: true, tagged: false });
|
||||
handle('plain paste');
|
||||
expect(overlay.pendingText).toBe('plain paste');
|
||||
expect(controller.completeCommit).not.toHaveBeenCalled();
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
it('sends a commit once without local echo and holds the preview until output arrives', () => {
|
||||
const { app, controller, sent, handle } = onDataApp({ localEcho: false });
|
||||
handle('日本語');
|
||||
expect(controller.consumeTerminalData).toHaveBeenCalledOnce();
|
||||
expect(sent).toEqual(['日本語']);
|
||||
expect(controller.completeCommit).not.toHaveBeenCalled();
|
||||
expect(app._mobileImeCommitOutputSeq).toBe(5);
|
||||
});
|
||||
|
||||
it('sends the original bytes exactly once when the controller throws', () => {
|
||||
const { sent, handle } = onDataApp({ localEcho: false, consumeThrows: true });
|
||||
expect(() => handle('你好')).not.toThrow();
|
||||
expect(sent).toEqual(['你好']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mobile IME commit and authoritative terminal output', () => {
|
||||
function outputHarness(mode = 'claude') {
|
||||
const { mixin } = loadMixin();
|
||||
const parses: Array<() => void> = [];
|
||||
const written: string[] = [];
|
||||
const controller = {
|
||||
consumeTerminalData: vi.fn(() => true),
|
||||
noteAuthoritativeOutput: vi.fn(),
|
||||
};
|
||||
const app: App = Object.assign(Object.create(mixin), {
|
||||
pendingWrites: [],
|
||||
terminal: {
|
||||
rows: 24,
|
||||
write: vi.fn((data: string, callback?: () => void) => {
|
||||
written.push(data);
|
||||
if (callback) parses.push(callback);
|
||||
}),
|
||||
buffer: { active: { viewportY: 0 } },
|
||||
scrollToBottom: vi.fn(),
|
||||
},
|
||||
activeSessionId: 'session-a',
|
||||
sessions: new Map([['session-a', { mode }]]),
|
||||
isTerminalAtBottom: () => true,
|
||||
_hasRecentUserScrollUp: () => false,
|
||||
_safeYield: vi.fn(),
|
||||
_localEchoOverlay: null,
|
||||
_mobileImePreview: controller,
|
||||
});
|
||||
const flush = () => {
|
||||
app.writeFrameScheduled = false;
|
||||
app.flushPendingWrites();
|
||||
};
|
||||
const parseNext = () => parses.shift()?.();
|
||||
return { app, controller, written, flush, parseNext };
|
||||
}
|
||||
|
||||
it('clears the committed preview once output accepted after the commit is parsed', () => {
|
||||
const { app, controller, flush, parseNext } = outputHarness();
|
||||
app._consumeMobileImeTerminalData('你好');
|
||||
app.batchTerminalWrite('echo');
|
||||
flush();
|
||||
expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
|
||||
parseNext();
|
||||
expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not let output queued before the commit clear it, even when it parses after', () => {
|
||||
const { app, controller, flush, parseNext } = outputHarness();
|
||||
app.batchTerminalWrite('before');
|
||||
flush();
|
||||
app._consumeMobileImeTerminalData('你好');
|
||||
parseNext();
|
||||
expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
|
||||
|
||||
app.batchTerminalWrite('after');
|
||||
flush();
|
||||
parseNext();
|
||||
expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not let a split chunk clear the commit before its remainder is written', () => {
|
||||
const { app, controller, written, flush, parseNext } = outputHarness('codex');
|
||||
app._consumeMobileImeTerminalData('你好');
|
||||
app.batchTerminalWrite('x'.repeat(40000));
|
||||
flush();
|
||||
parseNext();
|
||||
expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
|
||||
flush();
|
||||
parseNext();
|
||||
expect(written.join('')).toBe('x'.repeat(40000));
|
||||
expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not let output parsed after a session switch clear the new session preview', () => {
|
||||
const { app, controller, flush, parseNext } = outputHarness();
|
||||
app._consumeMobileImeTerminalData('你好');
|
||||
app.batchTerminalWrite('echo');
|
||||
flush();
|
||||
app.activeSessionId = 'session-b';
|
||||
parseNext();
|
||||
expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-places an overlay that holds only a composition after output (hasPending is false there)', () => {
|
||||
const { app, flush } = outputHarness();
|
||||
const overlay = { hasPending: false, pendingText: '', composition: '今日', rerender: vi.fn() };
|
||||
app._localEchoOverlay = overlay;
|
||||
app.batchTerminalWrite('output that moves the prompt');
|
||||
flush();
|
||||
expect(overlay.rerender).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('re-places it after a resize too: no rerender() site is gated on hasPending', () => {
|
||||
// The resize observer is a closure inside initTerminal(), so it is pinned
|
||||
// by source; the post-write follow runs against real xterm in
|
||||
// test/mobile-ime-preview.browser.test.ts.
|
||||
expect(terminalSource).toMatch(
|
||||
/this\.updateConnectionLines\(\);\s*(?:\/\/[^\n]*\n\s*)*this\._localEchoOverlay\?\.rerender\(\);/
|
||||
);
|
||||
expect(terminalSource).not.toMatch(/hasPending\)\s*\{?\s*this\._localEchoOverlay\.rerender\(\)/);
|
||||
});
|
||||
|
||||
it('notifies once per commit, never for later output', () => {
|
||||
const { app, controller, flush, parseNext } = outputHarness();
|
||||
app._consumeMobileImeTerminalData('你好');
|
||||
for (const chunk of ['a', 'b']) {
|
||||
app.batchTerminalWrite(chunk);
|
||||
flush();
|
||||
parseNext();
|
||||
}
|
||||
expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* The iOS IME preview controller against a REAL xterm 6 instance.
|
||||
*
|
||||
* The controller's logic is unit-tested in test/mobile-ime-preview.test.ts
|
||||
* with a stand-in for xterm. What only real xterm proves is the event ORDER:
|
||||
* `terminal.open()` registers xterm's keydown listener in the capture phase on
|
||||
* the helper textarea, and CompositionHelper.keydown finalizes a composition
|
||||
* there and emits the commit through onData synchronously. The controller must
|
||||
* observe that keydown first (capture phase on `terminal.element`), and must
|
||||
* finalize on exactly the keys xterm does.
|
||||
*
|
||||
* No server: a blank page loads the vendored xterm bundle and the controller.
|
||||
* Browser-driven, so it is excluded from `npm run test:ci` like the other
|
||||
* Playwright suites. Run locally:
|
||||
* npm run test:browser -- test/mobile-ime-preview.browser.test.ts
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { build } from 'esbuild';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
|
||||
type Step =
|
||||
| ['start']
|
||||
| ['update', string]
|
||||
| ['end', string]
|
||||
| ['key', number, string, boolean]
|
||||
| ['wait', number]
|
||||
| ['consume', string];
|
||||
|
||||
describe('mobile IME preview with real xterm', () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage();
|
||||
await page.setContent('<div id="t" style="width:600px;height:300px"></div>');
|
||||
await page.addScriptTag({ path: resolve(root, 'node_modules/@xterm/xterm/lib/xterm.js') });
|
||||
await page.addScriptTag({ path: resolve(root, 'src/web/public/mobile-ime-preview.js') });
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
});
|
||||
|
||||
async function drive(steps: Step[]) {
|
||||
return page.evaluate(async (steps: Step[]) => {
|
||||
const w = window as any;
|
||||
const host = document.getElementById('t') as HTMLElement;
|
||||
host.innerHTML = '';
|
||||
const term = new w.Terminal();
|
||||
term.open(host);
|
||||
const textarea = term.textarea as HTMLTextAreaElement;
|
||||
const renders: Array<{ text: string; phase: string }> = [];
|
||||
const onData: Array<{ data: string; consumed: boolean }> = [];
|
||||
const controller = w.MobileImePreview.create({
|
||||
textarea,
|
||||
keydownTarget: term.element,
|
||||
render: (r: { text: string; phase: string }) => renders.push(r),
|
||||
clear: () => {},
|
||||
});
|
||||
term.onData((data: string) => onData.push({ data, consumed: controller.consumeTerminalData(data) }));
|
||||
textarea.focus();
|
||||
const tick = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
for (const step of steps) {
|
||||
if (step[0] === 'start') textarea.dispatchEvent(new CompositionEvent('compositionstart', { data: '' }));
|
||||
if (step[0] === 'update') {
|
||||
textarea.value = step[1];
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: step[1] }));
|
||||
}
|
||||
if (step[0] === 'end') textarea.dispatchEvent(new CompositionEvent('compositionend', { data: step[1] }));
|
||||
if (step[0] === 'key') {
|
||||
const [, keyCode, key, isComposing] = step;
|
||||
const event = new KeyboardEvent('keydown', { key, isComposing, bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, 'keyCode', { get: () => keyCode });
|
||||
textarea.dispatchEvent(event);
|
||||
}
|
||||
if (step[0] === 'wait') await tick(step[1]);
|
||||
if (step[0] === 'consume') onData.push({ data: step[1], consumed: controller.consumeTerminalData(step[1]) });
|
||||
}
|
||||
await tick(20);
|
||||
const { composing, awaitingCommit, committed, latest } = controller.state;
|
||||
const result = { onData, state: { composing, awaitingCommit, committed, latest }, lastRender: renders.at(-1) };
|
||||
controller.destroy();
|
||||
term.dispose();
|
||||
return result;
|
||||
}, steps);
|
||||
}
|
||||
|
||||
it('Enter mid-composition: xterm emits the commit and the controller takes it as committed', async () => {
|
||||
// compositionupdate's textarea end offset is recorded by xterm on a 0 ms timer.
|
||||
const result = await drive([['start'], ['update', '確定'], ['wait', 10], ['key', 13, 'Enter', false]]);
|
||||
expect(result.onData).toEqual([
|
||||
{ data: '確定', consumed: true },
|
||||
{ data: '\r', consumed: false },
|
||||
]);
|
||||
expect(result.state).toMatchObject({ awaitingCommit: false, committed: true, latest: '確定' });
|
||||
expect(result.lastRender).toEqual({ text: '確定', phase: 'committed' });
|
||||
});
|
||||
|
||||
it('keyCode 229 with isComposing false: xterm keeps composing, so the preview keeps following', async () => {
|
||||
const result = await drive([
|
||||
['start'],
|
||||
['update', 'か'],
|
||||
['wait', 10],
|
||||
['key', 229, 'k', false],
|
||||
['update', 'かな'],
|
||||
]);
|
||||
expect(result.onData).toEqual([]);
|
||||
expect(result.state).toMatchObject({ composing: true, awaitingCommit: false, latest: 'かな' });
|
||||
expect(result.lastRender).toEqual({ text: 'かな', phase: 'provisional' });
|
||||
});
|
||||
|
||||
it('a deleted composition stops waiting after 2 s, so a later paste is not taken as its commit', async () => {
|
||||
const result = await drive([
|
||||
['start'],
|
||||
['update', 'abc'],
|
||||
['update', ''],
|
||||
['end', ''],
|
||||
['wait', 2100],
|
||||
['consume', 'pasted'],
|
||||
]);
|
||||
expect(result.onData).toEqual([{ data: 'pasted', consumed: false }]);
|
||||
expect(result.state).toMatchObject({ awaitingCommit: false, committed: false });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The preview with local echo ON, the default for Claude sessions on phones.
|
||||
* Committed text then sits in the LocalEchoOverlay (a z-index 7 layer in
|
||||
* `.xterm-screen`) and never reaches the PTY before Enter, so the PTY cursor,
|
||||
* which is where the helper span sits, stays at the prompt start: under the
|
||||
* overlay's own opaque text. So a composition that follows text already in the
|
||||
* overlay must be drawn by the overlay itself, after that text.
|
||||
*
|
||||
* Loads the real pieces: xterm 6, the overlay bundled from its package source
|
||||
* exactly as scripts/postinstall.js bundles it (plus the same LocalEchoOverlay
|
||||
* alias), styles.css, mobile-ime-preview.js, and terminal-ui.js's own
|
||||
* `_initMobileImePreview` on a bare CodemanApp prototype.
|
||||
*/
|
||||
describe('mobile IME preview over the local echo overlay', () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
const bundled = await build({
|
||||
entryPoints: [resolve(root, 'packages/xterm-zerolag-input/src/zerolag-input-addon.ts')],
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
globalName: 'XtermZerolagInput',
|
||||
write: false,
|
||||
logLevel: 'silent',
|
||||
});
|
||||
const overlayBundle =
|
||||
bundled.outputFiles[0].text +
|
||||
'\nwindow.ZerolagInputAddon=XtermZerolagInput.ZerolagInputAddon;' +
|
||||
'window.LocalEchoOverlay=class extends XtermZerolagInput.ZerolagInputAddon{' +
|
||||
'constructor(terminal){super({prompt:{type:"character",char:"\\u276f",offset:2}});this.activate(terminal);}};\n';
|
||||
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage({ viewport: { width: 800, height: 400 }, deviceScaleFactor: 1 });
|
||||
await page.setContent(
|
||||
'<html class="touch-device"><body><div id="t" style="width:600px;height:240px"></div></body></html>'
|
||||
);
|
||||
await page.addStyleTag({ path: resolve(root, 'node_modules/@xterm/xterm/css/xterm.css') });
|
||||
await page.addStyleTag({ content: readFileSync(resolve(root, 'src/web/public/styles.css'), 'utf8') });
|
||||
await page.addScriptTag({ path: resolve(root, 'node_modules/@xterm/xterm/lib/xterm.js') });
|
||||
await page.addScriptTag({ content: overlayBundle });
|
||||
await page.addScriptTag({ path: resolve(root, 'src/web/public/mobile-ime-preview.js') });
|
||||
await page.addScriptTag({ content: 'window.CodemanApp = class CodemanApp {};' });
|
||||
await page.addScriptTag({ path: resolve(root, 'src/web/public/terminal-ui.js') });
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
});
|
||||
|
||||
/**
|
||||
* Types `pending` into the overlay (as the printable/paste branch does), then
|
||||
* composes `composing` and reports what is PAINTED at the cell right after
|
||||
* the pending text and at the PTY cursor. Painted = topmost by hit-testing
|
||||
* with pointer-events forced on, since the overlay and the preview are
|
||||
* pointer-events:none.
|
||||
*/
|
||||
async function composeAfter(pending: string, composing: string, commit: boolean) {
|
||||
return page.evaluate(
|
||||
async ({ pending, composing, commit }) => {
|
||||
const w = window as any;
|
||||
const host = document.getElementById('t') as HTMLElement;
|
||||
host.innerHTML = '';
|
||||
const term = new w.Terminal({
|
||||
cols: 40,
|
||||
rows: 8,
|
||||
fontSize: 14,
|
||||
fontFamily: 'monospace',
|
||||
allowProposedApi: true,
|
||||
});
|
||||
term.open(host);
|
||||
await new Promise<void>((r) => term.write('\u276f ', () => r()));
|
||||
const app = new w.CodemanApp();
|
||||
app.terminal = term;
|
||||
app._localEchoEnabled = true;
|
||||
app._localEchoOverlay = new w.LocalEchoOverlay(term);
|
||||
w.MobileImePreview.isIosWebKitTouch = () => true;
|
||||
app._initMobileImePreview();
|
||||
|
||||
// The helper textarea and span follow the PTY cursor (col 2, row 0), as
|
||||
// _syncMobileHelperTextareaToCursor places them.
|
||||
const screen = term.element.querySelector('.xterm-screen') as HTMLElement;
|
||||
const dims = term._core._renderService.dimensions.css.cell;
|
||||
term.element.style.setProperty('--xterm-helper-left', 2 * dims.width + 'px');
|
||||
term.element.style.setProperty('--xterm-helper-top', '0px');
|
||||
|
||||
if (pending) app._localEchoOverlay.appendText(pending);
|
||||
const textarea = term.textarea as HTMLTextAreaElement;
|
||||
textarea.focus();
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionstart', { data: '' }));
|
||||
textarea.value = composing;
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: composing }));
|
||||
await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 20)));
|
||||
|
||||
const force = document.createElement('style');
|
||||
force.textContent = '.xterm * { pointer-events: auto !important; }';
|
||||
document.head.appendChild(force);
|
||||
const rect = screen.getBoundingClientRect();
|
||||
const widthOf = (s: string) => term._core.unicodeService.getStringCellWidth(s);
|
||||
const paintedAt = (col: number) => {
|
||||
const el = document.elementFromPoint(
|
||||
rect.left + (col + 0.5) * dims.width,
|
||||
rect.top + 0.5 * dims.height
|
||||
) as HTMLElement | null;
|
||||
return {
|
||||
text: el?.textContent ?? null,
|
||||
composition: !!el?.closest?.('[data-zerolag-composition]'),
|
||||
preview: !!el?.closest?.('.codeman-ime-preview'),
|
||||
};
|
||||
};
|
||||
const afterPending = paintedAt(2 + widthOf(pending));
|
||||
force.remove();
|
||||
|
||||
let afterCommit = null;
|
||||
if (commit) {
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: composing }));
|
||||
// What the printable/paste branch of terminal-ui.js's onData does.
|
||||
if (app._consumeMobileImeTerminalData(composing)) {
|
||||
app._localEchoOverlay.appendText(composing);
|
||||
app._transferMobileImeCommitToLocalEcho();
|
||||
}
|
||||
await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 20)));
|
||||
afterCommit = {
|
||||
pendingText: app._localEchoOverlay.pendingText,
|
||||
compositionSpans: term.element.querySelectorAll('[data-zerolag-composition]').length,
|
||||
overlayText: app._localEchoOverlay._overlay?.textContent,
|
||||
};
|
||||
}
|
||||
const result = {
|
||||
afterPending,
|
||||
pendingText: app._localEchoOverlay.pendingText,
|
||||
afterCommit,
|
||||
};
|
||||
app._destroyMobileImePreview();
|
||||
app._localEchoOverlay.dispose();
|
||||
term.dispose();
|
||||
return result;
|
||||
},
|
||||
{ pending, composing, commit }
|
||||
);
|
||||
}
|
||||
|
||||
it('first composition on an empty prompt: the overlay draws it at the prompt', async () => {
|
||||
const result = await composeAfter('', '今日は', false);
|
||||
expect(result.afterPending).toEqual({ text: '今', composition: true, preview: false });
|
||||
expect(result.pendingText).toBe('');
|
||||
});
|
||||
|
||||
it('a second composition is painted after the text already in the overlay, not under it', async () => {
|
||||
const result = await composeAfter('今日は', '天気', false);
|
||||
expect(result.afterPending.text).toBe('天');
|
||||
expect(result.afterPending.composition).toBe(true);
|
||||
// Provisional text is never taken into the overlay's pending (unsent) text.
|
||||
expect(result.pendingText).toBe('今日は');
|
||||
});
|
||||
|
||||
it('the commit lands once in the overlay and the composition tail is gone', async () => {
|
||||
const result = await composeAfter('今日は', '天気', true);
|
||||
expect(result.afterCommit).toEqual({ pendingText: '今日は天気', compositionSpans: 0, overlayText: '今日は天気' });
|
||||
});
|
||||
|
||||
/**
|
||||
* Composes `composing` after `pending`, then streams output through the REAL
|
||||
* write path (batchTerminalWrite, the scheduled flushPendingWrites, xterm's
|
||||
* async parse) that moves the ❯ row from 0 to 3, then one more frame that
|
||||
* leaves the prompt where it is (a status-line repaint). Reports the overlay
|
||||
* row after each frame.
|
||||
*
|
||||
* The post-write re-place runs right after terminal.write() returns, before
|
||||
* xterm parses that chunk, so it sees the buffer as of the previous frame: the
|
||||
* overlay reaches the new row on the frame after the move. That timing is the
|
||||
* same for pending text; the composition-only overlay used to never get there
|
||||
* because the re-place was gated on hasPending, which excludes it.
|
||||
*/
|
||||
async function composeThenMovePrompt(pending: string, composing: string) {
|
||||
return page.evaluate(
|
||||
async ({ pending, composing }) => {
|
||||
const w = window as any;
|
||||
const host = document.getElementById('t') as HTMLElement;
|
||||
host.innerHTML = '';
|
||||
const term = new w.Terminal({
|
||||
cols: 40,
|
||||
rows: 8,
|
||||
fontSize: 14,
|
||||
fontFamily: 'monospace',
|
||||
allowProposedApi: true,
|
||||
});
|
||||
term.open(host);
|
||||
await new Promise<void>((r) => term.write('❯ ', () => r()));
|
||||
const app = new w.CodemanApp();
|
||||
Object.assign(app, {
|
||||
terminal: term,
|
||||
_localEchoEnabled: true,
|
||||
_localEchoOverlay: new w.LocalEchoOverlay(term),
|
||||
pendingWrites: [],
|
||||
activeSessionId: 'session-a',
|
||||
sessions: new Map([['session-a', { mode: 'claude' }]]),
|
||||
});
|
||||
w.MobileImePreview.isIosWebKitTouch = () => true;
|
||||
app._initMobileImePreview();
|
||||
|
||||
if (pending) app._localEchoOverlay.appendText(pending);
|
||||
const textarea = term.textarea as HTMLTextAreaElement;
|
||||
textarea.focus();
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionstart', { data: '' }));
|
||||
textarea.value = composing;
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: composing }));
|
||||
await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 20)));
|
||||
|
||||
const cellH = term._core._renderService.dimensions.css.cell.height;
|
||||
const overlayEl = app._localEchoOverlay._overlay as HTMLElement;
|
||||
const overlayRow = () =>
|
||||
overlayEl.style.display === 'none' ? null : Math.round(parseFloat(overlayEl.style.top) / cellH);
|
||||
// Output goes through the app's own scheduler; wait until it has been
|
||||
// flushed and parsed.
|
||||
const stream = async (data: string) => {
|
||||
app.batchTerminalWrite(data);
|
||||
for (let i = 0; i < 200; i++) {
|
||||
if (!app.writeFrameScheduled && !app._terminalWriteInFlight && app.pendingWrites.length === 0) break;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
await new Promise<void>((r) => term.write('', () => r()));
|
||||
};
|
||||
|
||||
const before = overlayRow();
|
||||
await stream('\r\x1b[2Kline 1\r\nline 2\r\nline 3\r\n❯ ');
|
||||
const promptRow = app._localEchoOverlay.findPrompt()?.row ?? null;
|
||||
await stream('\x1b7\x1b[8;1Hworking\x1b8');
|
||||
const result = {
|
||||
before,
|
||||
promptRow,
|
||||
after: overlayRow(),
|
||||
composition: Array.from(term.element.querySelectorAll('[data-zerolag-composition]'))
|
||||
.map((el) => (el as HTMLElement).textContent)
|
||||
.join(''),
|
||||
hasPending: app._localEchoOverlay.hasPending,
|
||||
};
|
||||
app._destroyMobileImePreview();
|
||||
app._localEchoOverlay.dispose();
|
||||
term.dispose();
|
||||
return result;
|
||||
},
|
||||
{ pending, composing }
|
||||
);
|
||||
}
|
||||
|
||||
it('a composition on an empty prompt follows the prompt when output moves it', async () => {
|
||||
const result = await composeThenMovePrompt('', '今日');
|
||||
expect(result.before).toBe(0);
|
||||
expect(result.promptRow).toBe(3);
|
||||
// Nothing is pending: before the fix this stayed on row 0, over "line 1".
|
||||
expect(result.hasPending).toBe(false);
|
||||
expect(result.after).toBe(3);
|
||||
expect(result.composition).toBe('今日');
|
||||
});
|
||||
|
||||
it('a composition after pending text follows it the same way', async () => {
|
||||
const result = await composeThenMovePrompt('abc', '今日');
|
||||
expect(result).toEqual({ before: 0, promptRow: 3, after: 3, composition: '今日', hasPending: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,672 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
type Listener = (event: Record<string, unknown>) => void;
|
||||
type ListenerOptions = boolean | { capture?: boolean };
|
||||
type RegisteredListener = { listener: Listener; capture: boolean };
|
||||
|
||||
/**
|
||||
* A DOM node with just enough event dispatch to reproduce listener ORDER: an
|
||||
* ancestor's capture listeners, then the target's capture listeners, then the
|
||||
* target's bubble listeners (at-target capture-first, as in Chromium 89+ and
|
||||
* WebKit), then the ancestor's bubble listeners.
|
||||
*/
|
||||
class FakeNode {
|
||||
parent: FakeNode | null = null;
|
||||
protected listeners = new Map<string, RegisteredListener[]>();
|
||||
|
||||
addEventListener(type: string, listener: Listener, options?: ListenerOptions) {
|
||||
const listeners = this.listeners.get(type) ?? [];
|
||||
listeners.push({ listener, capture: options === true || options?.capture === true });
|
||||
this.listeners.set(type, listeners);
|
||||
}
|
||||
|
||||
removeEventListener(type: string, listener: Listener, options?: ListenerOptions) {
|
||||
const capture = options === true || options?.capture === true;
|
||||
const listeners = this.listeners.get(type) ?? [];
|
||||
const index = listeners.findIndex(
|
||||
(registered) => registered.listener === listener && registered.capture === capture
|
||||
);
|
||||
if (index >= 0) listeners.splice(index, 1);
|
||||
}
|
||||
|
||||
run(type: string, capture: boolean, event: Record<string, unknown>) {
|
||||
for (const registered of [...(this.listeners.get(type) ?? [])]) {
|
||||
if (registered.capture === capture) registered.listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(type: string, event: Record<string, unknown> = {}) {
|
||||
const full = { type, target: this, ...event };
|
||||
const ancestors: FakeNode[] = [];
|
||||
for (let node = this.parent; node; node = node.parent) ancestors.unshift(node);
|
||||
for (const ancestor of ancestors) ancestor.run(type, true, full);
|
||||
this.run(type, true, full);
|
||||
this.run(type, false, full);
|
||||
for (const ancestor of [...ancestors].reverse()) ancestor.run(type, false, full);
|
||||
}
|
||||
|
||||
listenerCount() {
|
||||
return [...this.listeners.values()].reduce((total, listeners) => total + listeners.length, 0);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeTextarea extends FakeNode {
|
||||
value = 'unchanged';
|
||||
}
|
||||
|
||||
type Scheduled = { id: number; callback: () => void; delay?: number };
|
||||
|
||||
function harness(
|
||||
overrides: Record<string, unknown> = {},
|
||||
beforeCreate?: (textarea: FakeTextarea, getController: () => Record<string, any> | undefined) => void
|
||||
) {
|
||||
const source = readFileSync(new URL('../src/web/public/mobile-ime-preview.js', import.meta.url), 'utf8');
|
||||
const context = vm.createContext({ navigator: {} });
|
||||
vm.runInContext(source, context, { filename: 'mobile-ime-preview.js' });
|
||||
const api = vm.runInContext('MobileImePreview', context);
|
||||
// The terminal element: an ancestor of the helper textarea, like xterm's
|
||||
// `.xterm` root is of `.xterm-helper-textarea`.
|
||||
const element = new FakeNode();
|
||||
const textarea = new FakeTextarea();
|
||||
textarea.parent = element;
|
||||
const frames: Scheduled[] = [];
|
||||
const timers: Scheduled[] = [];
|
||||
let nextId = 1;
|
||||
const render = vi.fn();
|
||||
const clear = vi.fn();
|
||||
const onCommit = vi.fn();
|
||||
const scheduleFrame = vi.fn((callback: () => void) => {
|
||||
const id = nextId++;
|
||||
frames.push({ id, callback });
|
||||
return id;
|
||||
});
|
||||
const cancelFrame = vi.fn((id: number) => {
|
||||
const index = frames.findIndex((frame) => frame.id === id);
|
||||
if (index >= 0) frames.splice(index, 1);
|
||||
});
|
||||
const setTimer = vi.fn((callback: () => void, delay: number) => {
|
||||
const id = nextId++;
|
||||
timers.push({ id, callback, delay });
|
||||
return id;
|
||||
});
|
||||
const clearTimer = vi.fn((id: number) => {
|
||||
const index = timers.findIndex((timer) => timer.id === id);
|
||||
if (index >= 0) timers.splice(index, 1);
|
||||
});
|
||||
let controller: Record<string, unknown> | undefined;
|
||||
beforeCreate?.(textarea, () => controller);
|
||||
controller = api.create({
|
||||
textarea,
|
||||
keydownTarget: element,
|
||||
render,
|
||||
clear,
|
||||
onCommit,
|
||||
scheduleFrame,
|
||||
cancelFrame,
|
||||
setTimer,
|
||||
clearTimer,
|
||||
...overrides,
|
||||
});
|
||||
const flushFrame = () => frames.shift()?.callback();
|
||||
const flushTimer = () => timers.shift()?.callback();
|
||||
|
||||
return {
|
||||
api,
|
||||
element,
|
||||
textarea,
|
||||
frames,
|
||||
timers,
|
||||
render,
|
||||
clear,
|
||||
onCommit,
|
||||
scheduleFrame,
|
||||
cancelFrame,
|
||||
setTimer,
|
||||
clearTimer,
|
||||
controller,
|
||||
flushFrame,
|
||||
flushTimer,
|
||||
};
|
||||
}
|
||||
|
||||
describe('MobileImePreview', () => {
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
|
||||
test('collapses 500 composition updates into one latest-state frame', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
for (let i = 0; i < 500; i += 1) h.textarea.dispatch('compositionupdate', { data: `value-${i}` });
|
||||
|
||||
expect(h.scheduleFrame).toHaveBeenCalledTimes(1);
|
||||
expect(h.render).not.toHaveBeenCalled();
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenCalledOnce();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: 'value-499', phase: 'provisional' });
|
||||
expect(h.onCommit).not.toHaveBeenCalled();
|
||||
expect(h.textarea.value).toBe('unchanged');
|
||||
});
|
||||
|
||||
test('replaces provisional text for replacement, backspace, and composing input', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'abcdef' });
|
||||
h.textarea.dispatch('compositionupdate', { data: 'xy' });
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: 'xy', phase: 'provisional' });
|
||||
|
||||
h.textarea.dispatch('input', { data: '', isComposing: true });
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: '', phase: 'provisional' });
|
||||
expect(h.onCommit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('caps the preview without changing the terminal handoff value', () => {
|
||||
const h = harness();
|
||||
const value = '界'.repeat(2050);
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: value });
|
||||
h.flushFrame();
|
||||
expect(h.render.mock.calls[0][0].text).toHaveLength(2048);
|
||||
h.textarea.dispatch('compositionend', { data: value });
|
||||
expect(h.controller.consumeTerminalData(value)).toBe(true);
|
||||
expect(h.onCommit).toHaveBeenCalledWith(value);
|
||||
});
|
||||
|
||||
test('hands off only the first safe xterm onData value after finalization', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: '日本語' });
|
||||
h.textarea.dispatch('compositionend', { data: '日本語' });
|
||||
|
||||
expect(h.controller.consumeTerminalData('日本語')).toBe(true);
|
||||
expect(h.controller.consumeTerminalData('日本語')).toBe(false);
|
||||
expect(h.onCommit).toHaveBeenCalledOnce();
|
||||
expect(h.render).not.toHaveBeenCalled();
|
||||
expect(h.frames).toHaveLength(1);
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: '日本語', phase: 'committed' });
|
||||
expect(h.setTimer).toHaveBeenCalledWith(expect.any(Function), 2000);
|
||||
});
|
||||
|
||||
test('defers finalization rendering and updates the queued frame phase to committed', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'draft' });
|
||||
h.textarea.dispatch('compositionend');
|
||||
expect(h.render).not.toHaveBeenCalled();
|
||||
expect(h.frames).toHaveLength(1);
|
||||
|
||||
expect(h.controller.consumeTerminalData('final')).toBe(true);
|
||||
expect(h.frames).toHaveLength(1);
|
||||
expect(h.render).not.toHaveBeenCalled();
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenCalledOnce();
|
||||
expect(h.render).toHaveBeenCalledWith({ text: 'final', phase: 'committed' });
|
||||
});
|
||||
|
||||
test('treats the first safe xterm onData value as authoritative over stale provisional data', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'teh' });
|
||||
h.textarea.dispatch('compositionend');
|
||||
|
||||
expect(h.controller.consumeTerminalData('the')).toBe(true);
|
||||
expect(h.onCommit).toHaveBeenCalledOnce();
|
||||
expect(h.onCommit).toHaveBeenCalledWith('the');
|
||||
expect(h.controller.consumeTerminalData('teh')).toBe(false);
|
||||
});
|
||||
|
||||
test.each(['', '\n', 'line\rbreak', 'two\nlines', '\u0003', '\u007f'])(
|
||||
'rejects non-printable or multiline terminal data %j without consuming the pending value',
|
||||
(rejected) => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionend', { data: rejected });
|
||||
expect(h.controller.consumeTerminalData(rejected)).toBe(false);
|
||||
expect(h.onCommit).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
test.each(['line\u2028break', 'line\u2029break'])(
|
||||
'rejects Unicode line separator terminal data %j without consuming the finalization fence',
|
||||
(rejected) => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionend');
|
||||
expect(h.controller.consumeTerminalData(rejected)).toBe(false);
|
||||
expect(h.controller.consumeTerminalData('safe')).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
test('keydown can finalize composition before a late compositionend', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: '確定' });
|
||||
h.textarea.dispatch('keydown', { key: 'Enter', keyCode: 13, isComposing: false });
|
||||
expect(h.controller.consumeTerminalData('確定')).toBe(true);
|
||||
h.textarea.dispatch('compositionend', { data: 'stale' });
|
||||
expect(h.controller.consumeTerminalData('stale')).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* Stand-in for xterm 6.0: `terminal.open()` registers a CAPTURE keydown
|
||||
* listener on the helper textarea (CoreBrowserTerminal.ts:379), and
|
||||
* CompositionHelper.keydown (CompositionHelper.ts:94-108) finalizes the
|
||||
* composition there, emitting the commit through onData synchronously, for
|
||||
* every keyCode except 20/229 and 16/17/18. It is registered BEFORE the
|
||||
* controller is created, exactly as terminal.open() precedes
|
||||
* _initMobileImePreview().
|
||||
*/
|
||||
function withXtermStandIn() {
|
||||
const emitted: Array<{ data: string; consumed: boolean }> = [];
|
||||
let composing = false;
|
||||
let composition = '';
|
||||
const h = harness({}, (textarea, getController) => {
|
||||
const emit = (data: string) => emitted.push({ data, consumed: getController()?.consumeTerminalData(data) });
|
||||
textarea.addEventListener('compositionstart', () => {
|
||||
composing = true;
|
||||
composition = '';
|
||||
});
|
||||
textarea.addEventListener('compositionupdate', (event) => {
|
||||
composition = String(event.data ?? '');
|
||||
});
|
||||
textarea.addEventListener(
|
||||
'keydown',
|
||||
(event) => {
|
||||
if (composing && ![20, 229, 16, 17, 18].includes(event.keyCode as number)) {
|
||||
composing = false;
|
||||
emit(composition);
|
||||
}
|
||||
if (event.keyCode === 13) emit('\r');
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
return { ...h, emitted, isXtermComposing: () => composing };
|
||||
}
|
||||
|
||||
test('Enter mid-composition hands the commit xterm emits in its capture keydown to the preview', () => {
|
||||
const h = withXtermStandIn();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: '確定' });
|
||||
h.flushFrame();
|
||||
h.textarea.dispatch('keydown', { key: 'Enter', keyCode: 13, isComposing: false });
|
||||
|
||||
expect(h.emitted).toEqual([
|
||||
{ data: '確定', consumed: true },
|
||||
{ data: '\r', consumed: false },
|
||||
]);
|
||||
expect(h.onCommit).toHaveBeenCalledWith('確定');
|
||||
expect(h.controller.state).toMatchObject({ composing: false, awaitingCommit: false, committed: true });
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: '確定', phase: 'committed' });
|
||||
|
||||
// The next unrelated keystroke is ordinary input, not an IME commit.
|
||||
expect(h.controller.consumeTerminalData('x')).toBe(false);
|
||||
});
|
||||
|
||||
test.each([
|
||||
// keyCode 229 with isComposing:false and a real key identity: xterm keeps
|
||||
// composing, so the controller must too.
|
||||
['the IME composition character', 'k', 229],
|
||||
['CapsLock', 'CapsLock', 20],
|
||||
['Shift', 'Shift', 16],
|
||||
['Control', 'Control', 17],
|
||||
['Alt', 'Alt', 18],
|
||||
])('a keydown for %s keeps tracking the composition xterm is still composing', (_label, key, keyCode) => {
|
||||
const h = withXtermStandIn();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'か' });
|
||||
h.textarea.dispatch('keydown', { key, keyCode, isComposing: false });
|
||||
expect(h.isXtermComposing()).toBe(true);
|
||||
expect(h.controller.state).toMatchObject({ composing: true, awaitingCommit: false });
|
||||
|
||||
// The preview follows the composition instead of freezing on the old value.
|
||||
h.textarea.dispatch('compositionupdate', { data: 'かな' });
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: 'かな', phase: 'provisional' });
|
||||
expect(h.emitted).toEqual([]);
|
||||
});
|
||||
|
||||
test('ignores keydowns that did not target the helper textarea', () => {
|
||||
const h = harness();
|
||||
const sibling = new FakeNode();
|
||||
sibling.parent = h.element;
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: '漢字' });
|
||||
sibling.dispatch('keydown', { key: 'Enter', keyCode: 13, isComposing: false });
|
||||
expect(h.controller.state).toMatchObject({ composing: true, awaitingCommit: false });
|
||||
});
|
||||
|
||||
test('destroy stops the controller observing keydown on the terminal element', () => {
|
||||
const h = withXtermStandIn();
|
||||
h.controller.destroy();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'later' });
|
||||
h.textarea.dispatch('keydown', { key: 'Enter', keyCode: 13, isComposing: false });
|
||||
expect(h.emitted).toEqual([
|
||||
{ data: 'later', consumed: false },
|
||||
{ data: '\r', consumed: false },
|
||||
]);
|
||||
expect(h.onCommit).not.toHaveBeenCalled();
|
||||
expect(h.element.listenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
describe('a commit that never reaches onData', () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
function realTimerHarness() {
|
||||
return harness({
|
||||
setTimer: (callback: () => void, delay: number) => setTimeout(callback, delay),
|
||||
clearTimer: (id: ReturnType<typeof setTimeout>) => clearTimeout(id),
|
||||
});
|
||||
}
|
||||
|
||||
test.each([
|
||||
['compositionend', (h: ReturnType<typeof harness>) => h.textarea.dispatch('compositionend', { data: '' })],
|
||||
[
|
||||
'a finalizing keydown',
|
||||
(h: ReturnType<typeof harness>) => h.textarea.dispatch('keydown', { key: 'Enter', keyCode: 13 }),
|
||||
],
|
||||
])('stops waiting after the same 2 s bound when finalized by %s', (_label, finalize) => {
|
||||
const h = realTimerHarness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'deleted' });
|
||||
finalize(h);
|
||||
h.clear.mockClear();
|
||||
expect(h.controller.state).toMatchObject({ awaitingCommit: true, timerPending: true });
|
||||
|
||||
vi.advanceTimersByTime(1999);
|
||||
expect(h.controller.state.awaitingCommit).toBe(true);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(h.controller.state).toMatchObject({ awaitingCommit: false, latest: '', timerPending: false });
|
||||
expect(h.clear).toHaveBeenCalledOnce();
|
||||
|
||||
// The next unrelated keystroke or paste is not adopted as the IME commit.
|
||||
expect(h.controller.consumeTerminalData('x')).toBe(false);
|
||||
expect(h.controller.consumeTerminalData('pasted line')).toBe(false);
|
||||
expect(h.onCommit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('a commit that arrives in time replaces the wait bound with the committed one', () => {
|
||||
const h = realTimerHarness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionend', { data: '日本' });
|
||||
vi.advanceTimersByTime(1500);
|
||||
expect(h.controller.consumeTerminalData('日本')).toBe(true);
|
||||
// The wait bound would have fired at 2000 ms; the committed bound runs
|
||||
// a full 2 s from the commit instead.
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(h.controller.state.committed).toBe(true);
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(h.controller.state.committed).toBe(false);
|
||||
});
|
||||
|
||||
test('a new composition cancels the previous wait bound', () => {
|
||||
const h = realTimerHarness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionend', { data: '' });
|
||||
vi.advanceTimersByTime(1500);
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'next' });
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(h.controller.state).toMatchObject({ composing: true, latest: 'next' });
|
||||
});
|
||||
});
|
||||
|
||||
test('generation fences stale frames and timers', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'old' });
|
||||
const staleFrame = h.frames[0].callback;
|
||||
h.textarea.dispatch('compositionstart');
|
||||
staleFrame();
|
||||
expect(h.render).not.toHaveBeenCalled();
|
||||
|
||||
h.textarea.dispatch('compositionend', { data: 'first' });
|
||||
h.controller.consumeTerminalData('first');
|
||||
const staleTimer = h.timers[0].callback;
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'new' });
|
||||
h.flushFrame();
|
||||
staleTimer();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: 'new', phase: 'provisional' });
|
||||
});
|
||||
|
||||
test('predicted completion clears immediately while fallback waits for output or TTL', () => {
|
||||
const predicted = harness();
|
||||
predicted.textarea.dispatch('compositionstart');
|
||||
predicted.clear.mockClear();
|
||||
predicted.textarea.dispatch('compositionend', { data: 'one' });
|
||||
predicted.controller.consumeTerminalData('one');
|
||||
expect(predicted.frames).toHaveLength(1);
|
||||
predicted.controller.completeCommit({ predicted: true });
|
||||
expect(predicted.clear).toHaveBeenCalledOnce();
|
||||
expect(predicted.frames).toHaveLength(0);
|
||||
expect(predicted.timers).toHaveLength(0);
|
||||
expect(predicted.controller.state.latest).toBe('');
|
||||
expect(predicted.controller.state.committed).toBe(false);
|
||||
|
||||
const fallback = harness();
|
||||
fallback.textarea.dispatch('compositionstart');
|
||||
fallback.clear.mockClear();
|
||||
fallback.textarea.dispatch('compositionend', { data: 'two' });
|
||||
fallback.controller.consumeTerminalData('two');
|
||||
fallback.controller.completeCommit({ predicted: false });
|
||||
expect(fallback.clear).not.toHaveBeenCalled();
|
||||
fallback.controller.noteAuthoritativeOutput();
|
||||
expect(fallback.clear).toHaveBeenCalledOnce();
|
||||
expect(fallback.frames).toHaveLength(0);
|
||||
expect(fallback.timers).toHaveLength(0);
|
||||
expect(fallback.controller.state.latest).toBe('');
|
||||
expect(fallback.controller.state.committed).toBe(false);
|
||||
|
||||
const ttl = harness();
|
||||
ttl.textarea.dispatch('compositionstart');
|
||||
ttl.clear.mockClear();
|
||||
ttl.textarea.dispatch('compositionend', { data: 'three' });
|
||||
ttl.controller.consumeTerminalData('three');
|
||||
ttl.controller.completeCommit({ predicted: false });
|
||||
ttl.flushTimer();
|
||||
expect(ttl.clear).toHaveBeenCalledOnce();
|
||||
expect(ttl.frames).toHaveLength(0);
|
||||
expect(ttl.timers).toHaveLength(0);
|
||||
expect(ttl.controller.state.latest).toBe('');
|
||||
expect(ttl.controller.state.committed).toBe(false);
|
||||
});
|
||||
|
||||
test('contains re-entrant reset and destroy from commit callbacks without resurrecting work', () => {
|
||||
let resetController: { reset(): void };
|
||||
const reset = harness({ onCommit: () => resetController.reset() });
|
||||
resetController = reset.controller;
|
||||
reset.textarea.dispatch('compositionstart');
|
||||
reset.textarea.dispatch('compositionupdate', { data: 'draft' });
|
||||
reset.textarea.dispatch('compositionend');
|
||||
expect(reset.controller.consumeTerminalData('final')).toBe(true);
|
||||
expect(reset.frames).toHaveLength(0);
|
||||
expect(reset.timers).toHaveLength(0);
|
||||
expect(reset.controller.state.latest).toBe('');
|
||||
|
||||
let destroyController: { destroy(): void };
|
||||
const destroy = harness({ onCommit: () => destroyController.destroy() });
|
||||
destroyController = destroy.controller;
|
||||
destroy.textarea.dispatch('compositionstart');
|
||||
destroy.textarea.dispatch('compositionend');
|
||||
expect(destroy.controller.consumeTerminalData('final')).toBe(true);
|
||||
expect(destroy.textarea.listenerCount()).toBe(0);
|
||||
expect(destroy.element.listenerCount()).toBe(0);
|
||||
expect(destroy.frames).toHaveLength(0);
|
||||
expect(destroy.timers).toHaveLength(0);
|
||||
expect(destroy.controller.state.latest).toBe('');
|
||||
});
|
||||
|
||||
test('contains re-entrant render and clear callbacks', () => {
|
||||
let renderController: { reset(): void };
|
||||
const render = harness({ render: () => renderController.reset() });
|
||||
renderController = render.controller;
|
||||
render.textarea.dispatch('compositionstart');
|
||||
render.textarea.dispatch('compositionupdate', { data: 'draft' });
|
||||
expect(() => render.flushFrame()).not.toThrow();
|
||||
expect(render.frames).toHaveLength(0);
|
||||
expect(render.timers).toHaveLength(0);
|
||||
expect(render.controller.state.latest).toBe('');
|
||||
|
||||
let clearController: { destroy(): void };
|
||||
const clear = harness({ clear: () => clearController?.destroy() });
|
||||
clearController = clear.controller;
|
||||
expect(() => clear.textarea.dispatch('compositionstart')).not.toThrow();
|
||||
expect(clear.textarea.listenerCount()).toBe(0);
|
||||
expect(clear.element.listenerCount()).toBe(0);
|
||||
expect(clear.frames).toHaveLength(0);
|
||||
expect(clear.timers).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('fails open when frame or timer schedulers throw', () => {
|
||||
const frame = harness({
|
||||
scheduleFrame: () => {
|
||||
throw new Error('frame scheduler');
|
||||
},
|
||||
});
|
||||
frame.textarea.dispatch('compositionstart');
|
||||
expect(() => frame.textarea.dispatch('compositionupdate', { data: 'safe' })).not.toThrow();
|
||||
expect(frame.controller.state.framePending).toBe(false);
|
||||
|
||||
let lateFrame: (() => void) | undefined;
|
||||
const timer = harness({
|
||||
scheduleFrame: (callback: () => void) => {
|
||||
lateFrame = callback;
|
||||
return 1;
|
||||
},
|
||||
cancelFrame: () => {},
|
||||
setTimer: () => {
|
||||
throw new Error('timer scheduler');
|
||||
},
|
||||
});
|
||||
timer.textarea.dispatch('compositionstart');
|
||||
timer.clear.mockClear();
|
||||
timer.textarea.dispatch('compositionend');
|
||||
expect(() => timer.controller.consumeTerminalData('safe')).not.toThrow();
|
||||
expect(timer.controller.state.committed).toBe(false);
|
||||
expect(timer.controller.state.latest).toBe('');
|
||||
expect(timer.controller.state.framePending).toBe(false);
|
||||
expect(timer.controller.state.timerPending).toBe(false);
|
||||
expect(timer.clear).toHaveBeenCalledOnce();
|
||||
lateFrame?.();
|
||||
expect(timer.render).not.toHaveBeenCalled();
|
||||
|
||||
const tokenlessTimer = harness({ setTimer: () => undefined });
|
||||
tokenlessTimer.textarea.dispatch('compositionstart');
|
||||
tokenlessTimer.clear.mockClear();
|
||||
tokenlessTimer.textarea.dispatch('compositionend');
|
||||
expect(tokenlessTimer.controller.consumeTerminalData('safe')).toBe(true);
|
||||
expect(tokenlessTimer.controller.state.committed).toBe(false);
|
||||
expect(tokenlessTimer.controller.state.latest).toBe('');
|
||||
expect(tokenlessTimer.controller.state.framePending).toBe(false);
|
||||
expect(tokenlessTimer.controller.state.timerPending).toBe(false);
|
||||
expect(tokenlessTimer.frames).toHaveLength(0);
|
||||
expect(tokenlessTimer.timers).toHaveLength(0);
|
||||
expect(tokenlessTimer.clear).toHaveBeenCalledOnce();
|
||||
|
||||
const cancellation = harness({
|
||||
cancelFrame: () => {
|
||||
throw new Error('frame cancellation');
|
||||
},
|
||||
clearTimer: () => {
|
||||
throw new Error('timer cancellation');
|
||||
},
|
||||
});
|
||||
cancellation.textarea.dispatch('compositionstart');
|
||||
cancellation.textarea.dispatch('compositionupdate', { data: 'draft' });
|
||||
cancellation.textarea.dispatch('compositionend');
|
||||
cancellation.controller.consumeTerminalData('final');
|
||||
expect(() => cancellation.controller.reset()).not.toThrow();
|
||||
expect(cancellation.controller.state.framePending).toBe(false);
|
||||
expect(cancellation.controller.state.timerPending).toBe(false);
|
||||
});
|
||||
|
||||
test('authoritative output never clears active provisional composition', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.clear.mockClear();
|
||||
h.textarea.dispatch('compositionupdate', { data: 'active' });
|
||||
h.flushFrame();
|
||||
h.controller.noteAuthoritativeOutput();
|
||||
expect(h.clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('blur and reset cancel scheduled work and clear visual state', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.clear.mockClear();
|
||||
h.textarea.dispatch('compositionupdate', { data: 'pending' });
|
||||
h.textarea.dispatch('blur');
|
||||
expect(h.frames).toHaveLength(0);
|
||||
expect(h.clear).toHaveBeenCalledOnce();
|
||||
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.clear.mockClear();
|
||||
h.textarea.dispatch('compositionupdate', { data: 'again' });
|
||||
h.controller.reset();
|
||||
expect(h.frames).toHaveLength(0);
|
||||
expect(h.clear).toHaveBeenCalledOnce();
|
||||
expect(h.controller.state.latest).toBe('');
|
||||
expect(h.controller.state.committed).toBe(false);
|
||||
});
|
||||
|
||||
test('destroy is idempotent and removes listeners and scheduled work', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'pending' });
|
||||
expect(h.textarea.listenerCount()).toBe(5);
|
||||
expect(h.element.listenerCount()).toBe(1);
|
||||
h.controller.destroy();
|
||||
h.controller.destroy();
|
||||
expect(h.textarea.listenerCount()).toBe(0);
|
||||
expect(h.element.listenerCount()).toBe(0);
|
||||
expect(h.frames).toHaveLength(0);
|
||||
h.textarea.dispatch('compositionupdate', { data: 'ignored' });
|
||||
expect(h.scheduleFrame).toHaveBeenCalledOnce();
|
||||
expect(() => h.controller.reset()).not.toThrow();
|
||||
expect(h.controller.state.latest).toBe('');
|
||||
expect(h.controller.state.framePending).toBe(false);
|
||||
expect(h.controller.state.timerPending).toBe(false);
|
||||
});
|
||||
|
||||
test('contains render, clear, and commit callback exceptions', () => {
|
||||
const h = harness({
|
||||
render: vi.fn(() => {
|
||||
throw new Error('render');
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
throw new Error('clear');
|
||||
}),
|
||||
onCommit: vi.fn(() => {
|
||||
throw new Error('commit');
|
||||
}),
|
||||
});
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'safe' });
|
||||
expect(() => h.flushFrame()).not.toThrow();
|
||||
h.textarea.dispatch('compositionend', { data: 'safe' });
|
||||
expect(() => h.controller.consumeTerminalData('safe')).not.toThrow();
|
||||
expect(() => h.controller.reset()).not.toThrow();
|
||||
});
|
||||
|
||||
test.each([
|
||||
[{ userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15', maxTouchPoints: 5 }, true],
|
||||
[{ userAgent: 'Mozilla/5.0 (iPad) AppleWebKit/605.1.15', maxTouchPoints: 5 }, true],
|
||||
[{ userAgent: 'Mozilla/5.0 (iPod) AppleWebKit/605.1.15', maxTouchPoints: 1 }, true],
|
||||
[{ userAgent: 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15', platform: 'MacIntel', maxTouchPoints: 2 }, true],
|
||||
[{ userAgent: 'CriOS/120.0 (iPhone) AppleWebKit/605.1.15', maxTouchPoints: 5 }, true],
|
||||
[{ userAgent: 'Mozilla/5.0 (iPhone) Gecko/120', maxTouchPoints: 5 }, false],
|
||||
[{ userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15', maxTouchPoints: 0 }, false],
|
||||
[{ userAgent: 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15', platform: 'MacIntel', maxTouchPoints: 1 }, false],
|
||||
[{ userAgent: 'Mozilla/5.0 (Android) AppleWebKit/537.36', maxTouchPoints: 5 }, false],
|
||||
])('detects iOS WebKit touch eligibility for %j', (nav, expected) => {
|
||||
expect(harness().api.isIosWebKitTouch(nav)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,7 @@ function makeApp() {
|
||||
getCaseSettings: () => ({}),
|
||||
buildEnvOverrides: () => ({}),
|
||||
getEffortSetting: () => undefined,
|
||||
getAdvisorSetting: () => undefined,
|
||||
selectSession: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,6 +104,20 @@ describe('clampExternalCliBypassForOwner — multi-user mode', () => {
|
||||
expect(out.grokConfig).toEqual({ alwaysApprove: false, model: 'grok-4.5' });
|
||||
});
|
||||
|
||||
it("keeps a non-granted owner's codex reasoning effort while forcing bypass off", async () => {
|
||||
// The clamp rewrites one field and must carry the rest; a clamp rebuilt from named
|
||||
// fields would drop the effort here without a word.
|
||||
const out = await _clampExternalCliBypassForOwner(
|
||||
'peon',
|
||||
{ dangerouslyBypassApprovals: true, reasoningEffort: 'xhigh' },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
expect(out.codexConfig).toEqual({ dangerouslyBypassApprovals: false, reasoningEffort: 'xhigh' });
|
||||
});
|
||||
|
||||
it('leaves codex/antigravity/grok absent when nothing was sent (they already spawn safe)', async () => {
|
||||
const out = await _clampExternalCliBypassForOwner('peon', undefined, undefined, undefined, undefined, undefined);
|
||||
expect(out.codexConfig).toBeUndefined();
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* @fileoverview Route tests for /api/mcp-sync. The feature is OPT-IN (`mcpSyncEnabled`, default
|
||||
* OFF): both verbs answer 403 until it is on. Only CLIs that are ENABLED in the registry take
|
||||
* part, and only if installed or already configured; enabled agent CLIs with no known MCP
|
||||
* config are reported as unsupported.
|
||||
*
|
||||
* ⚠️ test/setup.ts gives the whole FILE one temp HOME, so each test wipes the config files it
|
||||
* creates. Port: N/A (app.inject()).
|
||||
*/
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerMcpSyncRoutes } from '../../src/web/routes/mcp-sync-routes.js';
|
||||
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
|
||||
import { registryFilePath, reloadCliRegistry } from '../../src/config/cli-registry/registry.js';
|
||||
import { STOCK_CLIS } from '../../src/config/cli-registry/stock.js';
|
||||
|
||||
// Which CLIs are installed on the machine running the tests must not decide the outcome: nothing
|
||||
// is installed, so only a CLI whose config file exists takes part.
|
||||
// Lets a test hold the module's real apply lock open: the first apply parks inside the mock
|
||||
// (after taking the lock) until released, so a second POST deterministically overlaps it.
|
||||
const hold = vi.hoisted(() => ({ release: null as null | (() => void), entered: null as null | (() => void) }));
|
||||
vi.mock('../../src/mcp-sync.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/mcp-sync.js')>();
|
||||
return {
|
||||
...actual,
|
||||
syncMcpServers: async (...args: Parameters<typeof actual.syncMcpServers>) => {
|
||||
if (!hold.release || !args[1].apply) return actual.syncMcpServers(...args);
|
||||
const parked = new Promise<void>((resolve) => (hold.release = resolve));
|
||||
hold.entered?.();
|
||||
const pending = actual.syncMcpServers(...args); // takes the lock synchronously
|
||||
await parked;
|
||||
return pending;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Nothing is installed unless a test adds the id here.
|
||||
const installed = vi.hoisted(() => new Set<string>());
|
||||
vi.mock('../../src/utils/cli-installed-probes.js', () => ({
|
||||
probeStockCliAvailability: async () => ({}),
|
||||
isCliEntryInstalled: (e: { id: string }) => installed.has(e.id),
|
||||
}));
|
||||
|
||||
// The route follows each CLI's relocation env var (CODEX_HOME, CLAUDE_CONFIG_DIR, XDG_CONFIG_HOME,
|
||||
// ...) from process.env, so the runner's own values (CI images set XDG_CONFIG_HOME) must never
|
||||
// aim a test write outside the temp HOME. Cleared before every test, restored after the file.
|
||||
const RELOCATION_VARS = STOCK_CLIS.flatMap((e) => {
|
||||
const envVar = e.capabilities.mcpConfig?.relocation?.envVar;
|
||||
return envVar ? [envVar] : [];
|
||||
});
|
||||
const savedEnv = Object.fromEntries(RELOCATION_VARS.map((k) => [k, process.env[k]]));
|
||||
afterAll(() => {
|
||||
for (const [k, v] of Object.entries(savedEnv)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
});
|
||||
|
||||
const home = () => homedir();
|
||||
const write = (rel: string, text: string) => {
|
||||
const f = join(home(), rel);
|
||||
mkdirSync(dirname(f), { recursive: true });
|
||||
writeFileSync(f, text);
|
||||
};
|
||||
const setEnabled = (on: boolean | undefined) => {
|
||||
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify(on === undefined ? {} : { mcpSyncEnabled: on }));
|
||||
};
|
||||
const disable = (...ids: string[]) => {
|
||||
const file = registryFilePath();
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
const clis = Object.fromEntries(ids.map((id) => [id, { enabled: false }]));
|
||||
writeFileSync(file, JSON.stringify({ schemaVersion: 1, clis }), { mode: 0o600 });
|
||||
reloadCliRegistry();
|
||||
};
|
||||
|
||||
const CLAUDE = '.claude.json';
|
||||
const CODEX = '.codex/config.toml';
|
||||
const GEMINI = '.gemini/settings.json';
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of RELOCATION_VARS) delete process.env[k];
|
||||
installed.clear();
|
||||
rmSync(registryFilePath(), { force: true });
|
||||
reloadCliRegistry();
|
||||
for (const d of ['.claude.json', '.codex', '.gemini', '.config', 'relocated'])
|
||||
rmSync(join(home(), d), { recursive: true, force: true });
|
||||
write(CLAUDE, JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'] } } }));
|
||||
// Codex and Gemini have been set up on this machine (their config files exist).
|
||||
write(CODEX, 'model = "gpt-5"\n');
|
||||
write(GEMINI, '{}');
|
||||
setEnabled(true);
|
||||
});
|
||||
afterEach(() => {
|
||||
delete process.env.CODEMAN_MULTIUSER;
|
||||
rmSync(registryFilePath(), { force: true });
|
||||
rmSync(SETTINGS_PATH, { force: true });
|
||||
reloadCliRegistry();
|
||||
});
|
||||
|
||||
describe('/api/mcp-sync — opt-in', () => {
|
||||
it.each([
|
||||
['absent', undefined],
|
||||
['false', false],
|
||||
])('answers 403 on both verbs and writes nothing while the setting is %s', async (_label, value) => {
|
||||
setEnabled(value);
|
||||
const before = readFileSync(join(home(), CODEX), 'utf8');
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
for (const method of ['GET', 'POST'] as const) {
|
||||
const res = await app.inject({ method, url: '/api/mcp-sync' });
|
||||
expect(res.statusCode, method).toBe(403);
|
||||
expect(res.json().error).toMatch(/disabled/i);
|
||||
}
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
|
||||
});
|
||||
|
||||
it('applies the toggle on the next request, with no restart', async () => {
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
setEnabled(false);
|
||||
expect((await app.inject({ method: 'GET', url: '/api/mcp-sync' })).statusCode).toBe(403);
|
||||
setEnabled(true);
|
||||
expect((await app.inject({ method: 'GET', url: '/api/mcp-sync' })).statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/api/mcp-sync', () => {
|
||||
it('GET previews without writing', async () => {
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const before = readFileSync(join(home(), CODEX), 'utf8');
|
||||
const res = await app.inject({ method: 'GET', url: '/api/mcp-sync' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.applied).toBe(false);
|
||||
expect(body.data.targets.find((t: { id: string }) => t.id === 'codex').added).toEqual(['fs']);
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
|
||||
});
|
||||
|
||||
it('POST adds the server to every enabled, set-up CLI', async () => {
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
expect(res.json().data.applied).toBe(true);
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toContain('[mcp_servers.fs]');
|
||||
expect(JSON.parse(readFileSync(join(home(), GEMINI), 'utf8')).mcpServers.fs.command).toBe('npx');
|
||||
});
|
||||
|
||||
it('never creates config for an enabled CLI that is not installed and has no config file', async () => {
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
const opencode = res.json().data.targets.find((t: { id: string }) => t.id === 'opencode');
|
||||
// Nothing is installed (mocked) and opencode has no config under the temp HOME.
|
||||
expect(opencode.status).toBe('absent');
|
||||
expect(existsSync(join(home(), '.config'))).toBe(false);
|
||||
});
|
||||
|
||||
it('never touches a CLI that is disabled in the registry', async () => {
|
||||
disable('codex');
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const before = readFileSync(join(home(), CODEX), 'utf8');
|
||||
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
const ids = res.json().data.targets.map((t: { id: string }) => t.id);
|
||||
expect(ids).not.toContain('codex');
|
||||
expect(ids).toContain('gemini');
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
|
||||
expect(JSON.parse(readFileSync(join(home(), GEMINI), 'utf8')).mcpServers.fs.command).toBe('npx');
|
||||
});
|
||||
|
||||
it('lists installed, enabled agent CLIs without MCP support, and omits disabled, uninstalled ones and the shell', async () => {
|
||||
installed.add('grok').add('pi');
|
||||
disable('pi');
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const { unsupported } = (await app.inject({ method: 'GET', url: '/api/mcp-sync' })).json().data;
|
||||
expect(unsupported).toEqual(['Grok']);
|
||||
});
|
||||
|
||||
it('follows CODEX_HOME from the server env instead of writing the default ~/.codex', async () => {
|
||||
const codexHome = join(home(), 'relocated/codex');
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
write('relocated/codex/config.toml', 'model = "gpt-5"\n');
|
||||
const before = readFileSync(join(home(), CODEX), 'utf8');
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
const codex = res.json().data.targets.find((t: { id: string }) => t.id === 'codex');
|
||||
expect(codex.file).toBe(join(codexHome, 'config.toml'));
|
||||
expect(codex.added).toEqual(['fs']);
|
||||
expect(readFileSync(join(codexHome, 'config.toml'), 'utf8')).toContain('[mcp_servers.fs]');
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
|
||||
});
|
||||
|
||||
it('reports a relative CODEX_HOME as skipped and writes no codex file', async () => {
|
||||
process.env.CODEX_HOME = 'relative/codex';
|
||||
installed.add('codex');
|
||||
const before = readFileSync(join(home(), CODEX), 'utf8');
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
const codex = res.json().data.targets.find((t: { id: string }) => t.id === 'codex');
|
||||
expect(codex.status).toBe('skipped');
|
||||
expect(codex.error).toMatch(/CODEX_HOME/);
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
|
||||
});
|
||||
|
||||
it('never echoes the text of a config file it cannot parse', async () => {
|
||||
write(CODEX, 'model = "gpt-5"\n[mcp_servers.linear]\nenv = { LINEAR_API_KEY = "lin_SECRET_abc" broken }\n');
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const res = await app.inject({ method: 'GET', url: '/api/mcp-sync' });
|
||||
const codex = res.json().data.targets.find((t: { id: string }) => t.id === 'codex');
|
||||
expect(codex.status).toBe('unreadable');
|
||||
expect(codex.error).toMatch(/^not valid TOML \(line 3, column \d+\)$/);
|
||||
expect(res.body).not.toContain('lin_SECRET_abc');
|
||||
});
|
||||
|
||||
it('never returns env values or headers', async () => {
|
||||
write(
|
||||
CLAUDE,
|
||||
JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx', env: { TOKEN: 'sekrit-value' } } } })
|
||||
);
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
expect(res.body).not.toContain('sekrit-value');
|
||||
});
|
||||
|
||||
it('answers 409 to an apply that overlaps another, and a later apply succeeds', async () => {
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
hold.release = () => undefined;
|
||||
const entered = new Promise<void>((resolve) => (hold.entered = resolve));
|
||||
const first = app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
await entered; // the first apply now holds the lock
|
||||
const release = hold.release;
|
||||
hold.release = null; // the overlapping request goes straight to the real function
|
||||
const second = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
expect(second.statusCode).toBe(409);
|
||||
expect(second.json().errorCode).toBe('CONFLICT');
|
||||
release?.();
|
||||
expect((await first).statusCode).toBe(200);
|
||||
expect((await app.inject({ method: 'POST', url: '/api/mcp-sync' })).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('multi-user: a non-admin is refused on both verbs and nothing is written', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes, {
|
||||
authUser: { username: 'bob', role: 'user' },
|
||||
});
|
||||
const before = readFileSync(join(home(), CODEX), 'utf8');
|
||||
for (const method of ['GET', 'POST'] as const) {
|
||||
const res = await app.inject({ method, url: '/api/mcp-sync' });
|
||||
expect(res.statusCode, method).toBe(403);
|
||||
}
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
|
||||
});
|
||||
|
||||
it('multi-user: an admin is allowed', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes, {
|
||||
authUser: { username: 'root', role: 'admin' },
|
||||
});
|
||||
expect((await app.inject({ method: 'GET', url: '/api/mcp-sync' })).json().success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* @fileoverview `model` on POST /api/sessions — a Claude model for one session only.
|
||||
*
|
||||
* Claude's model reaches disk only through `modelOverride`, which writes it into the
|
||||
* case's `.claude/settings.local.json` for every later run there. `model` is the
|
||||
* per-session counterpart: it goes out as `claude --model <id>`, wins over the app-wide
|
||||
* default, and writes nothing. What the tests read is the model the session hands the
|
||||
* mux when it starts, which is what becomes the `--model` flag.
|
||||
*
|
||||
* Uses app.inject(), so no real HTTP port is needed.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
|
||||
interface Harness {
|
||||
app: FastifyInstance;
|
||||
ctx: MockRouteContext;
|
||||
}
|
||||
|
||||
async function createHarness(): Promise<Harness> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
const ctx = createMockRouteContext();
|
||||
registerSessionRoutes(app, ctx);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
return { app, ctx };
|
||||
}
|
||||
|
||||
describe('POST /api/sessions model', () => {
|
||||
let workingDir: string;
|
||||
let harness: Harness;
|
||||
|
||||
beforeEach(async () => {
|
||||
workingDir = await mkdtemp(join(tmpdir(), 'codeman-session-model-'));
|
||||
harness = await createHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
await rm(workingDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Creates a session and starts it, then returns the model it handed the mux. */
|
||||
async function launchedModel(payload: Record<string, unknown>): Promise<unknown> {
|
||||
const res = await harness.app.inject({ method: 'POST', url: '/api/sessions', payload: { workingDir, ...payload } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const parsed = JSON.parse(res.body);
|
||||
const id = (parsed.data?.session ?? parsed.session).id as string;
|
||||
await harness.app.inject({ method: 'POST', url: `/api/sessions/${id}/interactive`, payload: {} });
|
||||
const calls = harness.ctx.mux.createSession.mock.calls;
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
return (calls[calls.length - 1][0] as { model?: string }).model;
|
||||
}
|
||||
|
||||
it('launches a Claude session on the model the caller names', async () => {
|
||||
expect(await launchedModel({ mode: 'claude', model: 'claude-fable-5-1' })).toBe('claude-fable-5-1');
|
||||
});
|
||||
|
||||
it('wins over the app-wide default model', async () => {
|
||||
harness.ctx.getModelConfig.mockResolvedValue({ defaultModel: 'sonnet' });
|
||||
expect(await launchedModel({ mode: 'claude', model: 'opus' })).toBe('opus');
|
||||
});
|
||||
|
||||
it('leaves the app-wide default in charge when the caller names none', async () => {
|
||||
harness.ctx.getModelConfig.mockResolvedValue({ defaultModel: 'sonnet' });
|
||||
expect(await launchedModel({ mode: 'claude' })).toBe('sonnet');
|
||||
});
|
||||
|
||||
it('launches on `model` while `modelOverride` alone reaches the case file', async () => {
|
||||
// Sent together, each lands where it belongs: the persistent default in the case's
|
||||
// settings.local.json, and this session's model on its launch line. A route that wrote
|
||||
// `model` to disk would put 'opus' in the file; one that ignored it would launch 'sonnet'.
|
||||
expect(await launchedModel({ mode: 'claude', model: 'opus', modelOverride: 'sonnet' })).toBe('opus');
|
||||
const settings = JSON.parse(await readFile(join(workingDir, '.claude', 'settings.local.json'), 'utf8'));
|
||||
expect(settings.model).toBe('sonnet');
|
||||
});
|
||||
|
||||
it('reads an empty model as no model, as modelOverride does', async () => {
|
||||
harness.ctx.getModelConfig.mockResolvedValue({ defaultModel: 'sonnet' });
|
||||
expect(await launchedModel({ mode: 'claude', model: '' })).toBe('sonnet');
|
||||
});
|
||||
|
||||
it('refuses a model for a CLI that takes its model in its own config object', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { workingDir, mode: 'codex', model: 'gpt-5' },
|
||||
});
|
||||
const parsed = JSON.parse(res.body);
|
||||
expect(parsed.success).toBe(false);
|
||||
expect(parsed.errorCode).toBe('INVALID_INPUT');
|
||||
expect(harness.ctx.sessions.size).toBe(1); // only the session the mock context starts with
|
||||
});
|
||||
|
||||
it('rejects a model with characters the launch pattern refuses', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { workingDir, mode: 'claude', model: 'opus; rm -rf ~' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects a flag-shaped model, since the value lands in argv', async () => {
|
||||
for (const model of ['--dangerously-skip-permissions', '-p', '.hidden', '[1m]']) {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { workingDir, mode: 'claude', model },
|
||||
});
|
||||
expect(res.statusCode, model).toBe(400);
|
||||
}
|
||||
expect(harness.ctx.sessions.size).toBe(1); // only the session the mock context starts with
|
||||
});
|
||||
|
||||
it('still accepts real model ids, aliases and the [1m] suffix', async () => {
|
||||
for (const model of ['claude-fable-5-1', 'opus', 'opus[1m]', 'claude-opus-5-5[1m]']) {
|
||||
expect(await launchedModel({ mode: 'claude', model })).toBe(model);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['model', { model: 'opus' }],
|
||||
['advisorModel', { advisorModel: 'opus' }],
|
||||
])('refuses %s on a remote attach, which launches nothing', async (_field, extra) => {
|
||||
// Refused before the host is looked up, so no remote host needs to exist.
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: {
|
||||
mode: 'claude',
|
||||
attachRemoteSession: { hostId: 'h1', remoteSessionName: 'codeman-ssh-abc123' },
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
const parsed = JSON.parse(res.body);
|
||||
expect(parsed.success).toBe(false);
|
||||
expect(parsed.errorCode).toBe('INVALID_INPUT');
|
||||
expect(harness.ctx.sessions.size).toBe(1);
|
||||
});
|
||||
|
||||
it('publishes the launch model on the created claude session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { workingDir, mode: 'claude', model: 'claude-fable-5-1', advisorModel: 'opus' },
|
||||
});
|
||||
const parsed = JSON.parse(res.body);
|
||||
const session = parsed.data?.session ?? parsed.session;
|
||||
expect(session.model).toBe('claude-fable-5-1');
|
||||
expect(session.advisorModel).toBe('opus');
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyMultipart from '@fastify/multipart';
|
||||
import { join } from 'node:path';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { registryFilePath, reloadCliRegistry } from '../../src/config/cli-registry/registry.js';
|
||||
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
@@ -165,6 +167,74 @@ describe('session-routes', () => {
|
||||
expect(argv).toContain('-H');
|
||||
});
|
||||
|
||||
describe('newline chord comes from the CLI registry (capabilities.newline)', () => {
|
||||
const sentHex = async (mode: string, key: string): Promise<string[]> => {
|
||||
execFile.mockReset();
|
||||
execFile.mockImplementation((_bin: string, _argv: string[], _opts: unknown, cb: (e: Error | null) => void) =>
|
||||
cb(null)
|
||||
);
|
||||
const session = harness.ctx._session as unknown as { mode: string };
|
||||
const before = session.mode;
|
||||
session.mode = mode;
|
||||
try {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/test-session-1/send-key',
|
||||
payload: { key },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
} finally {
|
||||
session.mode = before;
|
||||
}
|
||||
const argv = execFile.mock.calls[0][1] as string[];
|
||||
return argv.slice(argv.indexOf('-H') + 3); // after "-H -t <pane>"
|
||||
};
|
||||
|
||||
it('sends a line feed for Shift+Enter to a CLI that declares nothing', async () => {
|
||||
expect(await sentHex('claude', 'S-Enter')).toEqual(['0a']);
|
||||
expect(await sentHex('opencode', 'S-Enter')).toEqual(['0a']);
|
||||
});
|
||||
|
||||
it('sends a line feed to Codex too: no stock CLI declares a chord', async () => {
|
||||
expect(await sentHex('codex', 'S-Enter')).toEqual(['0a']);
|
||||
});
|
||||
|
||||
describe('a CLI that declares esc-enter (here via a user clis.json override of codex)', () => {
|
||||
beforeEach(() => {
|
||||
const file = registryFilePath();
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify({ schemaVersion: 1, clis: { codex: { capabilities: { newline: 'esc-enter' } } } }),
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
reloadCliRegistry();
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(registryFilePath(), { force: true });
|
||||
reloadCliRegistry();
|
||||
});
|
||||
|
||||
it('sends Esc+Enter for Shift+Enter, and only to that CLI', async () => {
|
||||
expect(await sentHex('codex', 'S-Enter')).toEqual(['1b', '0d']);
|
||||
expect(await sentHex('claude', 'S-Enter')).toEqual(['0a']);
|
||||
});
|
||||
|
||||
it('still sends a line feed for Ctrl+Enter', async () => {
|
||||
expect(await sentHex('codex', 'C-Enter')).toEqual(['0a']);
|
||||
});
|
||||
});
|
||||
|
||||
it('always sends a line feed for Ctrl+Enter', async () => {
|
||||
expect(await sentHex('codex', 'C-Enter')).toEqual(['0a']);
|
||||
expect(await sentHex('claude', 'C-Enter')).toEqual(['0a']);
|
||||
});
|
||||
|
||||
it('falls back to a line feed for a mode the registry does not know', async () => {
|
||||
expect(await sentHex('no-such-cli', 'S-Enter')).toEqual(['0a']);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects keys outside the hex allowlist without invoking tmux', async () => {
|
||||
execFile.mockReset();
|
||||
const res = await harness.app.inject({
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* @fileoverview /api/webhook: the webhook-notification config. The URL is a bearer secret, so it
|
||||
* is never returned and the routes are admin only in multi-user mode.
|
||||
* Port: N/A (app.inject()).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync, rmSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerWebhookRoutes } from '../../src/web/routes/webhook-routes.js';
|
||||
import { readWebhookConfig, webhookConfigPath, WebhookNotifier, type WebhookFetch } from '../../src/webhook-notify.js';
|
||||
|
||||
const SECRET_URL = 'https://hooks.slack.com/services/T0/B0/SUPERSECRET';
|
||||
|
||||
let dir: string;
|
||||
let fetchImpl: ReturnType<typeof vi.fn<WebhookFetch>>;
|
||||
|
||||
async function harness(authUser?: { username: string; role: 'admin' | 'user' }) {
|
||||
const notifier = new WebhookNotifier(() => readWebhookConfig(dir), fetchImpl);
|
||||
const h = await createRouteTestHarness(
|
||||
(app) => registerWebhookRoutes(app, { notifier, configDir: dir, hostTitle: () => 'codeman:test' }),
|
||||
authUser ? { authUser } : undefined
|
||||
);
|
||||
return { ...h, notifier };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'webhook-routes-'));
|
||||
fetchImpl = vi.fn<WebhookFetch>(async () => new Response('', { status: 200 }));
|
||||
});
|
||||
afterEach(() => {
|
||||
delete process.env.CODEMAN_MULTIUSER;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('GET /api/webhook', () => {
|
||||
it('starts disabled with no URL', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({ method: 'GET', url: '/api/webhook' });
|
||||
expect(res.json().data).toEqual({
|
||||
enabled: false,
|
||||
kind: 'ntfy',
|
||||
scope: 'attention',
|
||||
hasUrl: false,
|
||||
urlMasked: '',
|
||||
lastResult: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/webhook', () => {
|
||||
it('saves the config, masks the URL in every response, and writes the file 0600', async () => {
|
||||
const { app } = await harness();
|
||||
const put = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/webhook',
|
||||
payload: { enabled: true, kind: 'slack', scope: 'all', url: SECRET_URL },
|
||||
});
|
||||
expect(put.statusCode).toBe(200);
|
||||
expect(put.json().data).toMatchObject({ enabled: true, kind: 'slack', scope: 'all', hasUrl: true });
|
||||
expect(put.json().data.urlMasked).toBe('https://hooks.slack.com/•••');
|
||||
const get = await app.inject({ method: 'GET', url: '/api/webhook' });
|
||||
for (const body of [put.body, get.body]) expect(body).not.toMatch(/SUPERSECRET|T0\/B0/);
|
||||
expect((await readWebhookConfig(dir)).url).toBe(SECRET_URL);
|
||||
expect(statSync(webhookConfigPath(dir)).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('changing kind or scope keeps the saved URL (the secret is never re-sent)', async () => {
|
||||
const { app } = await harness();
|
||||
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { enabled: true, url: SECRET_URL } });
|
||||
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { kind: 'discord' } });
|
||||
expect(await readWebhookConfig(dir)).toMatchObject({ kind: 'discord', url: SECRET_URL, enabled: true });
|
||||
});
|
||||
|
||||
it('an empty url clears it', async () => {
|
||||
const { app } = await harness();
|
||||
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: SECRET_URL } });
|
||||
const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: '' } });
|
||||
expect(res.json().data).toMatchObject({ hasUrl: false, urlMasked: '' });
|
||||
expect((await readWebhookConfig(dir)).url).toBe('');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['enabling with no URL', { enabled: true }, /Add a webhook URL/],
|
||||
['a metadata address', { url: 'http://169.254.169.254/latest' }, /metadata|link-local/],
|
||||
['a non-http scheme', { url: 'file:///etc/passwd' }, /http and https/],
|
||||
['credentials in the URL', { url: 'https://u:p@example.com/x' }, /credentials/],
|
||||
['clearing the URL while enabled', null, /Add a webhook URL/],
|
||||
])('rejects %s with 400 and saves nothing', async (_label, payload, why) => {
|
||||
const { app } = await harness();
|
||||
if (payload === null) {
|
||||
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { enabled: true, url: SECRET_URL } });
|
||||
const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: '' } });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toMatch(why);
|
||||
expect((await readWebhookConfig(dir)).url).toBe(SECRET_URL);
|
||||
return;
|
||||
}
|
||||
const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toMatch(why);
|
||||
expect(await readWebhookConfig(dir)).toMatchObject({ enabled: false, url: '' });
|
||||
});
|
||||
|
||||
it('rejects unknown keys and bad enums (strict schema)', async () => {
|
||||
const { app } = await harness();
|
||||
for (const payload of [{ extra: 1 }, { kind: 'telegram' }, { scope: 'everything' }, { enabled: 'yes' }]) {
|
||||
const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload });
|
||||
expect(res.statusCode, JSON.stringify(payload)).toBe(400);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/webhook/test', () => {
|
||||
it('refuses with 400 until a URL is saved', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({ method: 'POST', url: '/api/webhook/test' });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends one message with the saved config, even while notifications are disabled', async () => {
|
||||
const { app, notifier } = await harness();
|
||||
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { kind: 'generic', url: SECRET_URL } });
|
||||
const res = await app.inject({ method: 'POST', url: '/api/webhook/test' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data).toMatchObject({ ok: true, status: 200 });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(fetchImpl.mock.calls[0][1].body as string)).toMatchObject({
|
||||
host: 'codeman:test',
|
||||
event: 'webhook:test',
|
||||
});
|
||||
expect(notifier.lastResult?.ok).toBe(true);
|
||||
expect(res.body).not.toContain('SUPERSECRET');
|
||||
});
|
||||
|
||||
it('reports a delivery failure in data (HTTP 200) without leaking the URL, and GET shows it as the last result', async () => {
|
||||
fetchImpl.mockImplementation(async () => new Response('', { status: 404 }));
|
||||
const { app } = await harness();
|
||||
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: SECRET_URL } });
|
||||
const res = await app.inject({ method: 'POST', url: '/api/webhook/test' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data).toMatchObject({ ok: false, status: 404, error: 'HTTP 404' });
|
||||
const get = await app.inject({ method: 'GET', url: '/api/webhook' });
|
||||
expect(get.json().data.lastResult).toMatchObject({ ok: false, status: 404 });
|
||||
expect(get.body).not.toContain('SUPERSECRET');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-user', () => {
|
||||
it.each([
|
||||
['GET', '/api/webhook'],
|
||||
['PUT', '/api/webhook'],
|
||||
['POST', '/api/webhook/test'],
|
||||
] as const)('refuses a non-admin on %s %s and touches nothing', async (method, url) => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { app } = await harness({ username: 'bob', role: 'user' });
|
||||
const res = await app.inject({ method, url, payload: method === 'PUT' ? { url: SECRET_URL } : undefined });
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect((await readWebhookConfig(dir)).url).toBe('');
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows an admin', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { app } = await harness({ username: 'root', role: 'admin' });
|
||||
expect((await app.inject({ method: 'GET', url: '/api/webhook' })).statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* @fileoverview A session's launch model survives recovery.
|
||||
*
|
||||
* `Session._model` is what becomes `claude --model <id>`: the caller's per-session `model`
|
||||
* from POST /api/sessions, or the app-wide default. It lives in memory, so it reaches a
|
||||
* relaunch after a Codeman restart or a reboot restore only if `toState()` persists it and
|
||||
* both recovery constructors hand it back. Without that, a recovered session silently
|
||||
* relaunches on the account default.
|
||||
*
|
||||
* `restoreMuxSessions()` (server.ts) cannot be reached under vitest, where
|
||||
* `reconcileSessions()` reports every pane alive, and the reboot-restore route rejects every
|
||||
* workspace before building a Session in its route tests. The two constructors are therefore
|
||||
* pinned by a source check, the same way `test/remote-wake.test.ts` pins its wiring, and the
|
||||
* round trip itself is driven through a real `Session` against the in-memory tmux layer.
|
||||
*/
|
||||
import { mkdirSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { Session } from '../src/session.js';
|
||||
import { TmuxManager, buildSpawnCommand } from '../src/tmux-manager.js';
|
||||
import type { MuxSession, RespawnPaneOptions, TerminalMultiplexer } from '../src/mux-interface.js';
|
||||
import type { EffortLevel } from '../src/types.js';
|
||||
|
||||
const SRC = fileURLToPath(new URL('../src', import.meta.url));
|
||||
|
||||
describe('the launch model survives recovery', () => {
|
||||
const workingDir = join(homedir(), 'codeman-cases', 'session-model-recovery');
|
||||
const sessions: Session[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const s of sessions.splice(0)) s.stop();
|
||||
rmSync(workingDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('persists the model in the session state', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude', model: 'claude-fable-5-1' });
|
||||
sessions.push(session);
|
||||
expect(session.toState().model).toBe('claude-fable-5-1');
|
||||
});
|
||||
|
||||
it('relaunches a session rebuilt from that state on the same model', async () => {
|
||||
mkdirSync(workingDir, { recursive: true });
|
||||
const original = new Session({ workingDir, mode: 'claude', model: 'claude-fable-5-1' });
|
||||
sessions.push(original);
|
||||
const state = original.toState();
|
||||
|
||||
// Rebuilt the way both recovery paths build one, from the persisted record.
|
||||
const mux = new TmuxManager();
|
||||
const createSession = vi.spyOn(mux, 'createSession');
|
||||
const rebuilt = new Session({
|
||||
id: state.id,
|
||||
workingDir,
|
||||
mode: state.mode,
|
||||
mux,
|
||||
useMux: true,
|
||||
model: state.model,
|
||||
});
|
||||
sessions.push(rebuilt);
|
||||
await rebuilt.startInteractive();
|
||||
|
||||
expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ model: 'claude-fable-5-1' }));
|
||||
});
|
||||
|
||||
it('is handed back by both recovery constructors', () => {
|
||||
const server = readFileSync(join(SRC, 'web', 'server.ts'), 'utf-8');
|
||||
const reboot = readFileSync(join(SRC, 'web', 'routes', 'reboot-restore-routes.ts'), 'utf-8');
|
||||
expect(server).toMatch(/model:\s*savedState\?\.model,/);
|
||||
expect(reboot).toMatch(/model:\s*saved\.model,/);
|
||||
});
|
||||
|
||||
it('is neither published nor persisted for a CLI that keeps its model in its own config', () => {
|
||||
// Cron hands the app-wide default (a Claude id) to every CLI that has a model at all.
|
||||
// codex never launches on the top-level field, so publishing it would report a model the
|
||||
// session never ran on, and recovery would carry that wrong value forward.
|
||||
for (const mode of ['codex', 'opencode', 'shell'] as const) {
|
||||
const session = new Session({ workingDir: '/tmp', mode, model: 'claude-fable-5-1' });
|
||||
sessions.push(session);
|
||||
expect(session.toState().model, mode).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #514 (per-session `model` → `--model`) and #530 (`advisorModel` → the launch's ONE
|
||||
* `--settings` JSON) landed together and touch the same launch and recovery code. A claude
|
||||
* session carrying both, with or without ultracode (whose blob shares that `--settings`
|
||||
* object), must relaunch with both on every recovery path.
|
||||
*/
|
||||
describe('a launch model and an advisor survive recovery together', () => {
|
||||
const MODEL = 'claude-fable-5-1';
|
||||
const ADVISOR = 'opus';
|
||||
const sessions: Session[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const s of sessions.splice(0)) await s.stop();
|
||||
});
|
||||
|
||||
/** The settings JSON exactly as a shell would hand it to claude. */
|
||||
function settingsOf(cmd: string): unknown {
|
||||
expect(cmd.match(/--settings /g)).toHaveLength(1);
|
||||
const tail = cmd.slice(cmd.indexOf('--settings '));
|
||||
return JSON.parse(execFileSync('bash', ['-c', `set -- ${tail}; printf '%s' "$2"`]).toString());
|
||||
}
|
||||
|
||||
/** Render what tmux-manager hands buildSpawnCommand for these options. */
|
||||
function launchLine(options: Pick<RespawnPaneOptions, 'sessionId' | 'model' | 'effort' | 'advisorModel'>): string {
|
||||
return buildSpawnCommand({
|
||||
mode: 'claude',
|
||||
sessionId: options.sessionId,
|
||||
model: options.model,
|
||||
effort: options.effort,
|
||||
advisorModel: options.advisorModel,
|
||||
claudeCliVersion: null,
|
||||
});
|
||||
}
|
||||
|
||||
function expectBoth(cmd: string, effort: EffortLevel | undefined): void {
|
||||
expect(cmd).toContain(`--model "${MODEL}"`);
|
||||
expect(cmd).not.toContain('--advisor ');
|
||||
expect(settingsOf(cmd)).toEqual(
|
||||
effort === 'ultracode' ? { ultracode: true, advisorModel: ADVISOR } : { advisorModel: ADVISOR }
|
||||
);
|
||||
if (effort && effort !== 'ultracode') expect(cmd).toContain(`--effort '${effort}'`);
|
||||
}
|
||||
|
||||
function persistedRecord(effort: EffortLevel | undefined) {
|
||||
const original = new Session({ workingDir: '/tmp', mode: 'claude', model: MODEL, advisorModel: ADVISOR, effort });
|
||||
sessions.push(original);
|
||||
const state = original.toState();
|
||||
expect(state.model).toBe(MODEL);
|
||||
expect(state.advisorModel).toBe(ADVISOR);
|
||||
return state;
|
||||
}
|
||||
|
||||
it.each([['ultracode'], ['high'], [undefined]] as const)(
|
||||
'reboot restore (fresh pane) relaunches on both, effort %s',
|
||||
async (effort) => {
|
||||
const state = persistedRecord(effort);
|
||||
// The reboot-restore constructor: no muxSession, so startInteractive() creates a pane.
|
||||
const mux = new TmuxManager();
|
||||
const createSession = vi.spyOn(mux, 'createSession');
|
||||
const rebuilt = new Session({
|
||||
id: state.id,
|
||||
workingDir: '/tmp',
|
||||
mode: state.mode,
|
||||
mux,
|
||||
useMux: true,
|
||||
effort: state.effort,
|
||||
model: state.model,
|
||||
advisorModel: state.advisorModel,
|
||||
});
|
||||
sessions.push(rebuilt);
|
||||
await rebuilt.startInteractive();
|
||||
|
||||
expect(createSession).toHaveBeenCalledTimes(1);
|
||||
const options = createSession.mock.calls[0][0];
|
||||
expect(options).toEqual(expect.objectContaining({ model: MODEL, advisorModel: ADVISOR, effort }));
|
||||
expectBoth(launchLine(options), effort);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([['ultracode'], ['high'], [undefined]] as const)(
|
||||
'restoreMuxSessions onto a dead pane respawns on both, effort %s',
|
||||
async (effort) => {
|
||||
const state = persistedRecord(effort);
|
||||
const respawns: RespawnPaneOptions[] = [];
|
||||
const mux = {
|
||||
isAvailable: () => true,
|
||||
muxSessionExists: () => true,
|
||||
isPaneDead: () => true,
|
||||
setAttached: () => {},
|
||||
respawnPane: async (options: RespawnPaneOptions) => {
|
||||
respawns.push(options);
|
||||
return 4242;
|
||||
},
|
||||
} as unknown as TerminalMultiplexer;
|
||||
// The restoreMuxSessions() constructor: an existing muxSession, whose pane is dead, so
|
||||
// startInteractive() takes the dead-pane respawn.
|
||||
const rebuilt = new Session({
|
||||
id: state.id,
|
||||
workingDir: '/tmp',
|
||||
mode: state.mode,
|
||||
mux,
|
||||
useMux: true,
|
||||
muxSession: { muxName: 'codeman-aaaa', sessionId: state.id } as unknown as MuxSession,
|
||||
effort: state.effort,
|
||||
model: state.model,
|
||||
advisorModel: state.advisorModel,
|
||||
});
|
||||
sessions.push(rebuilt);
|
||||
await rebuilt.startInteractive();
|
||||
|
||||
expect(respawns).toHaveLength(1);
|
||||
expect(respawns[0]).toEqual(expect.objectContaining({ model: MODEL, advisorModel: ADVISOR, effort }));
|
||||
expectBoth(launchLine(respawns[0]), effort);
|
||||
}
|
||||
);
|
||||
|
||||
it('both recovery constructors hand back model, advisorModel and effort', () => {
|
||||
const server = readFileSync(join(SRC, 'web', 'server.ts'), 'utf-8');
|
||||
const reboot = readFileSync(join(SRC, 'web', 'routes', 'reboot-restore-routes.ts'), 'utf-8');
|
||||
for (const field of ['effort', 'model', 'advisorModel']) {
|
||||
expect(server).toMatch(new RegExp(`\\b${field}:\\s*savedState\\?\\.${field},`));
|
||||
expect(reboot).toMatch(new RegExp(`\\b${field}:\\s*saved\\.${field},`));
|
||||
}
|
||||
});
|
||||
|
||||
it('tmux-manager forwards model, effort and advisorModel to both launch builders', () => {
|
||||
// createSession() and respawnPane() each build the pane command. The tests above stop at
|
||||
// the options a Session hands them, so this pins the last hop to the builder.
|
||||
const tmux = readFileSync(join(SRC, 'tmux-manager.ts'), 'utf-8');
|
||||
const calls = [...tmux.matchAll(/buildSpawnCommand\(\{([^}]*)\}\)/g)].map((m) => m[1]);
|
||||
expect(calls).toHaveLength(2);
|
||||
for (const call of calls) {
|
||||
for (const field of ['model', 'effort', 'advisorModel']) expect(call).toMatch(new RegExp(`\\b${field},`));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
// @vitest-environment node
|
||||
// Regression guard: xterm runs the custom key handler for keydown AND keypress.
|
||||
// It discards a keypress carrying Ctrl/Alt but not one carrying only Shift, so
|
||||
// a handler that returns false for keydown alone lets Shift+Enter's keypress
|
||||
// through as a bare \r (submit). The Enter gate must therefore not be keyed on
|
||||
// ev.type === 'keydown'; only the send-key fetch is.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const PUBLIC = join(new URL('.', import.meta.url).pathname, '../src/web/public');
|
||||
|
||||
describe.each(['terminal-ui.js', 'terminal-split.js'])('%s Shift/Ctrl+Enter handler', (file) => {
|
||||
const src = readFileSync(join(PUBLIC, file), 'utf8');
|
||||
|
||||
it('swallows every event type for Shift/Ctrl+Enter', () => {
|
||||
expect(src).toMatch(/ev\.key === 'Enter' && \(ev\.shiftKey \|\| ev\.ctrlKey\)\) \{/);
|
||||
expect(src).not.toMatch(/ev\.key === 'Enter' && \(ev\.shiftKey \|\| ev\.ctrlKey\) && ev\.type === 'keydown'/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* @fileoverview Shift/Ctrl+Enter against the SHIPPED key handlers, with real keystrokes in Chromium.
|
||||
*
|
||||
* xterm runs the custom key handler for keydown, keypress AND keyup, and drops a keypress that
|
||||
* carries Ctrl/Alt but NOT a Shift-only one. A handler that returns false for keydown alone
|
||||
* therefore lets Shift+Enter's keypress fall through to onData as a bare \r, which went out over
|
||||
* the WebSocket ahead of the async send-key fetch and submitted the prompt (#520). Ctrl+Enter
|
||||
* worked all along because Chromium fires no keypress for it.
|
||||
*
|
||||
* The page is the real app served by a real WebServer, so the handlers under test are the ones
|
||||
* terminal-ui.js (the main pane, `app.terminal`) and terminal-split.js (Pane B, a real
|
||||
* `SplitTerminalPane`) attach. Nothing restates their predicate. What stands in for the server is
|
||||
* only the edge: a fetch wrapper records the send-key POSTs instead of letting them reach tmux, and
|
||||
* no session exists behind the ids, so nothing is ever typed into a real pane.
|
||||
*
|
||||
* Browser-driven, so it is excluded from `npm run test:ci` like the other Playwright suites:
|
||||
* npm run test:browser -- test/shift-enter-keypress.browser.test.ts
|
||||
* The CI gate's guard on the same handlers is the source check in
|
||||
* test/shift-enter-keypress-swallowed.test.ts.
|
||||
*
|
||||
* Port: 3267 (per CLAUDE.md, ports 3150+ for tests)
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3267;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
const MAIN_ID = 'shift-enter-probe-main';
|
||||
const PANE_B_ID = 'shift-enter-probe-pane-b';
|
||||
|
||||
interface Pressed {
|
||||
/** What xterm emitted through onData, i.e. what would have been written to the PTY. */
|
||||
data: string[];
|
||||
/** The send-key POSTs the handler made, as `<session id> <key>`. */
|
||||
sendKeys: string[];
|
||||
/** Whether xterm's own textarea held focus, so a key that emits nothing was really delivered. */
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
describe('Shift/Ctrl+Enter through the shipped key handlers (keypress must be swallowed too)', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(PORT, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage();
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
await page.evaluate(
|
||||
({ paneBId }) => {
|
||||
const w = window as any;
|
||||
// Record send-key instead of reaching the server (no tmux pane exists behind these ids).
|
||||
w.__sendKeys = [] as string[];
|
||||
const realFetch = window.fetch.bind(window);
|
||||
window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
const m = /\/api\/sessions\/([^/]+)\/send-key$/.exec(url);
|
||||
if (m) {
|
||||
w.__sendKeys.push(`${m[1]} ${JSON.parse(String(init?.body ?? '{}')).key}`);
|
||||
return Promise.resolve(
|
||||
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
);
|
||||
}
|
||||
return realFetch(input, init);
|
||||
};
|
||||
|
||||
// Pane B, built by the real class. connect() installs the key handler synchronously,
|
||||
// before its first await; the buffer fetch and WebSocket for the missing session just fail.
|
||||
const mount = document.createElement('div');
|
||||
mount.style.cssText = 'position:fixed;left:0;top:0;width:400px;height:300px;';
|
||||
document.body.appendChild(mount);
|
||||
const pane = new w.SplitTerminalPane(paneBId, mount, { mode: 'claude' });
|
||||
void pane.connect().catch(() => {});
|
||||
// What xterm emits here is exactly what Pane B's own onData forwards to its WebSocket.
|
||||
w.__paneBData = [] as string[];
|
||||
pane.terminal.onData((d: string) => w.__paneBData.push(d));
|
||||
w.__paneB = pane;
|
||||
|
||||
// The bare xterm the old keydown-only gate is reproduced on (see the first test).
|
||||
const host = document.createElement('div');
|
||||
host.style.cssText = 'position:fixed;left:420px;top:0;width:400px;height:300px;';
|
||||
document.body.appendChild(host);
|
||||
const old = new w.Terminal();
|
||||
old.open(host);
|
||||
w.__oldData = [] as string[];
|
||||
old.onData((d: string) => w.__oldData.push(d));
|
||||
old.attachCustomKeyEventHandler(
|
||||
(ev: KeyboardEvent) => !(ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown')
|
||||
);
|
||||
w.__oldTerm = old;
|
||||
},
|
||||
{ paneBId: PANE_B_ID }
|
||||
);
|
||||
}, 90000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
}, 60000);
|
||||
|
||||
/**
|
||||
* Press `key` in the main pane. `sessionId` null is the welcome screen; otherwise the app thinks
|
||||
* that session is active, local echo is off (the desktop default) and its real send path,
|
||||
* `_sendInputAsync`, is recorded instead of posting.
|
||||
*/
|
||||
async function pressMain(key: string, sessionId: string | null): Promise<Pressed> {
|
||||
await page.evaluate((id) => {
|
||||
const w = window as any;
|
||||
const app = w.app;
|
||||
w.__mainSaved ??= { id: app.activeSessionId, echo: app._localEchoEnabled, send: app._sendInputAsync };
|
||||
w.__mainData = [] as string[];
|
||||
w.__sendKeys.length = 0;
|
||||
app.activeSessionId = id;
|
||||
app._localEchoEnabled = false;
|
||||
app._pendingInput = '';
|
||||
app._sendInputAsync = (_sid: string, chunk: string) => void w.__mainData.push(chunk);
|
||||
app.terminal.focus();
|
||||
}, sessionId);
|
||||
const focused = await page.evaluate(() => document.activeElement === (window as any).app.terminal.textarea);
|
||||
await page.keyboard.press(key);
|
||||
// The main pane batches onData through a short flush timer before _sendInputAsync.
|
||||
await page.waitForTimeout(150);
|
||||
return page.evaluate((f) => {
|
||||
const w = window as any;
|
||||
const app = w.app;
|
||||
const out = { data: [...w.__mainData], sendKeys: [...w.__sendKeys], focused: f };
|
||||
app.activeSessionId = w.__mainSaved.id;
|
||||
app._localEchoEnabled = w.__mainSaved.echo;
|
||||
app._sendInputAsync = w.__mainSaved.send;
|
||||
app._pendingInput = '';
|
||||
return out;
|
||||
}, focused);
|
||||
}
|
||||
|
||||
async function pressIn(which: 'paneB' | 'old', key: string): Promise<Pressed> {
|
||||
await page.evaluate((k) => {
|
||||
const w = window as any;
|
||||
w[k === 'paneB' ? '__paneBData' : '__oldData'].length = 0;
|
||||
w.__sendKeys.length = 0;
|
||||
(k === 'paneB' ? w.__paneB.terminal : w.__oldTerm).focus();
|
||||
}, which);
|
||||
const focused = await page.evaluate((k) => {
|
||||
const w = window as any;
|
||||
return document.activeElement === (k === 'paneB' ? w.__paneB.terminal : w.__oldTerm).textarea;
|
||||
}, which);
|
||||
await page.keyboard.press(key);
|
||||
await page.waitForTimeout(50);
|
||||
return page.evaluate(
|
||||
({ k, f }) => {
|
||||
const w = window as any;
|
||||
return { data: [...w[k === 'paneB' ? '__paneBData' : '__oldData']], sendKeys: [...w.__sendKeys], focused: f };
|
||||
},
|
||||
{ k: which, f: focused }
|
||||
);
|
||||
}
|
||||
|
||||
it('reproduces the leak on a bare xterm with the old keydown-only gate (the behaviour the fix depends on)', async () => {
|
||||
// Not shipped code: this pins the xterm behaviour, so a future xterm that stops emitting the
|
||||
// Shift-only keypress shows up here instead of silently turning the tests below vacuous.
|
||||
const shift = await pressIn('old', 'Shift+Enter');
|
||||
expect(shift.focused).toBe(true);
|
||||
expect(shift.data).toEqual(['\r']);
|
||||
expect((await pressIn('old', 'Control+Enter')).data).toEqual([]);
|
||||
});
|
||||
|
||||
it('main pane (terminal-ui.js): Shift+Enter and Ctrl+Enter write nothing and POST send-key exactly once', async () => {
|
||||
const shift = await pressMain('Shift+Enter', MAIN_ID);
|
||||
expect(shift.focused).toBe(true);
|
||||
expect(shift.data).toEqual([]);
|
||||
// Once, from the keydown: the swallowed keypress and keyup must not send again.
|
||||
expect(shift.sendKeys).toEqual([`${MAIN_ID} S-Enter`]);
|
||||
|
||||
const ctrl = await pressMain('Control+Enter', MAIN_ID);
|
||||
expect(ctrl.data).toEqual([]);
|
||||
expect(ctrl.sendKeys).toEqual([`${MAIN_ID} C-Enter`]);
|
||||
});
|
||||
|
||||
it('main pane: plain Enter still reaches the send path as \\r, and Alt+Enter as ESC CR', async () => {
|
||||
const enter = await pressMain('Enter', MAIN_ID);
|
||||
expect(enter.focused).toBe(true);
|
||||
expect(enter.data).toEqual(['\r']);
|
||||
expect(enter.sendKeys).toEqual([]);
|
||||
|
||||
const alt = await pressMain('Alt+Enter', MAIN_ID);
|
||||
expect(alt.data).toEqual(['\x1b\r']);
|
||||
expect(alt.sendKeys).toEqual([]);
|
||||
});
|
||||
|
||||
it('main pane on the welcome screen: Shift+Enter sends nothing and posts no send-key', async () => {
|
||||
// With no active session the app's onData drops input anyway, so this pins the send-key
|
||||
// guard (`this.activeSessionId`), not the keypress swallow; the tests above pin that.
|
||||
const shift = await pressMain('Shift+Enter', null);
|
||||
expect(shift.focused).toBe(true);
|
||||
expect(shift.data).toEqual([]);
|
||||
expect(shift.sendKeys).toEqual([]);
|
||||
});
|
||||
|
||||
it("Pane B (terminal-split.js): Shift+Enter and Ctrl+Enter write nothing and POST send-key once for Pane B's own session", async () => {
|
||||
const shift = await pressIn('paneB', 'Shift+Enter');
|
||||
expect(shift.focused).toBe(true);
|
||||
expect(shift.data).toEqual([]);
|
||||
expect(shift.sendKeys).toEqual([`${PANE_B_ID} S-Enter`]);
|
||||
|
||||
const ctrl = await pressIn('paneB', 'Control+Enter');
|
||||
expect(ctrl.data).toEqual([]);
|
||||
expect(ctrl.sendKeys).toEqual([`${PANE_B_ID} C-Enter`]);
|
||||
|
||||
const enter = await pressIn('paneB', 'Enter');
|
||||
expect(enter.data).toEqual(['\r']);
|
||||
expect(enter.sendKeys).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,8 @@ type PaneUnderTest = {
|
||||
const fetchMock = vi.fn();
|
||||
/** requestAnimationFrame stand-in: chunked writes queue here and are drained by hand. */
|
||||
const rafQueue: Array<() => void> = [];
|
||||
/** Recorded deadline timers (see the context's setTimeout); `fn` aborts the request. */
|
||||
const deadlines: Array<{ fn: () => void; ms: number; cleared: boolean }> = [];
|
||||
const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-split.js'), 'utf8');
|
||||
|
||||
function loadSplitTerminalPane() {
|
||||
@@ -80,9 +82,27 @@ function loadSplitTerminalPane() {
|
||||
// compares it with the pane's own row count.
|
||||
window: {
|
||||
app: { _estimateReplayRows: (text: string) => text.split('\n').length },
|
||||
AbortSignal: { timeout: (ms: number) => ({ timeoutMs: ms }) },
|
||||
// The primary pane's capture budget (constants.js): the full-capture default.
|
||||
CodemanFetchDeadline: { terminalFetchDeadlineMs: () => 45_000 },
|
||||
AbortController: class {
|
||||
signal = { aborted: false };
|
||||
abort() {
|
||||
this.signal.aborted = true;
|
||||
}
|
||||
},
|
||||
},
|
||||
performance: { now: () => clock },
|
||||
// Deadline timers (>= 1 s) are recorded, never run: a test fires one by hand
|
||||
// and reads what it aborted. Anything shorter (xterm chunk pacing) is real.
|
||||
setTimeout: (fn: () => void, ms?: number) => {
|
||||
if ((ms ?? 0) < 1000) return setTimeout(fn, ms);
|
||||
deadlines.push({ fn, ms: ms as number, cleared: false });
|
||||
return -deadlines.length; // negative: never collides with a real timer id
|
||||
},
|
||||
clearTimeout: (id: unknown) => {
|
||||
if (typeof id === 'number' && id < 0) deadlines[-id - 1].cleared = true;
|
||||
else clearTimeout(id as Parameters<typeof clearTimeout>[0]);
|
||||
},
|
||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||
requestAnimationFrame: (fn: () => void) => rafQueue.push(fn),
|
||||
// The constants.js globals the module reads at call time.
|
||||
@@ -126,6 +146,27 @@ function jsonResponse(terminalBuffer: string, extra: Record<string, unknown> = {
|
||||
return { json: async () => ({ data: { terminalBuffer, ...extra } }) };
|
||||
}
|
||||
|
||||
/**
|
||||
* A response whose headers have landed but whose body has not: the window in
|
||||
* which the pull's live queue is open and nothing else has happened yet.
|
||||
* `release(buffer)` delivers the body; `fail()` errors the body read.
|
||||
*/
|
||||
function headersOnly() {
|
||||
let release!: (body: ReturnType<typeof jsonResponse> | Error) => void;
|
||||
const body = new Promise<{ data: Record<string, unknown> }>((resolve, reject) => {
|
||||
release = (value) => {
|
||||
if (value instanceof Error) reject(value);
|
||||
else void value.json().then(resolve);
|
||||
};
|
||||
});
|
||||
return {
|
||||
response: { json: () => body },
|
||||
release: (terminalBuffer: string, extra: Record<string, unknown> = {}) =>
|
||||
release(jsonResponse(terminalBuffer, extra)),
|
||||
fail: () => release(new Error('body read failed')),
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => {
|
||||
@@ -142,6 +183,7 @@ const settle = () => new Promise((r) => setTimeout(r, 0));
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
rafQueue.length = 0;
|
||||
deadlines.length = 0;
|
||||
clock = 0;
|
||||
});
|
||||
|
||||
@@ -301,10 +343,9 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
||||
pane._maybeLoadMoreHistory();
|
||||
await settle();
|
||||
|
||||
// With a deadline: live output is held for as long as the pull runs, so a
|
||||
// request that never answers would freeze the pane.
|
||||
// With a deadline, so a request that never answers cannot pin the pane.
|
||||
expect(fetchMock).toHaveBeenCalledWith(`/api/sessions/s1/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, {
|
||||
signal: { timeoutMs: 10_000 },
|
||||
signal: expect.objectContaining({ aborted: false }),
|
||||
});
|
||||
expect(term.write).toHaveBeenCalledWith('\x1bc');
|
||||
expect(term.write).toHaveBeenCalledWith(rowsOf(100));
|
||||
@@ -463,12 +504,14 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
expect(pane._liveQueue).toEqual([]);
|
||||
// The queue opens with the response, not the request.
|
||||
expect(pane._liveQueue).toBeNull();
|
||||
|
||||
// Arrives before the response does: it is IN the capture already.
|
||||
// Arrives before the response does: written straight through (the pane
|
||||
// keeps painting during the round trip), and the replay then replaces it.
|
||||
clock = 1;
|
||||
pane._onLiveOutput('early');
|
||||
expect(term.write).not.toHaveBeenCalledWith('early');
|
||||
expect(term.write).toHaveBeenCalledWith('early');
|
||||
await settle();
|
||||
|
||||
// 200 rows (more than the pane holds, so it replays) of 400 columns each:
|
||||
@@ -490,7 +533,9 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
||||
await settle();
|
||||
|
||||
const written = term.write.mock.calls.map((call) => call[0]);
|
||||
expect(written).not.toContain('early');
|
||||
// 'early' went out before the reset, so the replay wiped it and it is not repeated.
|
||||
expect(written.indexOf('early')).toBeLessThan(written.indexOf('\x1bc'));
|
||||
expect(written.filter((w) => w === 'early')).toHaveLength(1);
|
||||
expect(written.at(-1)).toBe('late');
|
||||
expect(pane._liveQueue).toBeNull();
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
@@ -498,26 +543,29 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
||||
|
||||
it('writes every held frame when the pull ends without replaying', async () => {
|
||||
const pane = makePane('shell');
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
const held = headersOnly();
|
||||
fetchMock.mockResolvedValueOnce(held.response);
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
pane._onLiveOutput('held');
|
||||
await settle();
|
||||
response.resolve(jsonResponse(rowsOf(30))); // nothing to gain: no replay
|
||||
pane._onLiveOutput('held');
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith('held');
|
||||
held.release(rowsOf(30)); // nothing to gain: no replay
|
||||
await settle();
|
||||
|
||||
// Nothing replaced the terminal, so the frame is news even though it
|
||||
// arrived before the response did.
|
||||
// Nothing replaced the terminal, so the held frame is news.
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith('held');
|
||||
});
|
||||
|
||||
it('a failed fetch releases the flag and the queue, so live output flows again', async () => {
|
||||
const pane = makePane('shell');
|
||||
fetchMock.mockRejectedValueOnce(new Error('offline'));
|
||||
const held = headersOnly();
|
||||
fetchMock.mockResolvedValueOnce(held.response);
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
await settle();
|
||||
pane._onLiveOutput('held');
|
||||
held.fail(); // the body read dies with the queue open
|
||||
await settle();
|
||||
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
@@ -554,17 +602,18 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
||||
done?.();
|
||||
});
|
||||
term.clear.mockImplementation(() => order.push('clear'));
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
const held = headersOnly();
|
||||
fetchMock.mockResolvedValueOnce(held.response);
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
await settle();
|
||||
pane._onLiveOutput('before');
|
||||
pane._onLiveClear();
|
||||
pane._onLiveOutput('after');
|
||||
// Held: clearing now would wipe a half-written snapshot.
|
||||
expect(order).toEqual([]);
|
||||
|
||||
response.resolve(jsonResponse(rowsOf(30))); // nothing to gain: no replay
|
||||
held.release(rowsOf(30)); // nothing to gain: no replay
|
||||
await settle();
|
||||
|
||||
expect(order).toEqual(['write:before', 'clear', 'write:after']);
|
||||
@@ -583,25 +632,27 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
clock = 1;
|
||||
pane._onLiveClear(); // already reflected in the capture
|
||||
pane._onLiveClear(); // before the response: applied now, already in the capture
|
||||
expect(term.clear).toHaveBeenCalledTimes(1);
|
||||
clock = 2;
|
||||
response.resolve(jsonResponse(rowsOf(100)));
|
||||
await settle();
|
||||
|
||||
expect(term.write).toHaveBeenCalledWith('\x1bc');
|
||||
expect(term.clear).not.toHaveBeenCalled();
|
||||
expect(term.clear).toHaveBeenCalledTimes(1); // not replayed after the capture
|
||||
});
|
||||
|
||||
it('destroy() mid-pull leaves nothing running and nothing written to the dead terminal', async () => {
|
||||
const pane = makePane('shell');
|
||||
const term = pane.terminal;
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
const held = headersOnly();
|
||||
fetchMock.mockResolvedValueOnce(held.response);
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
await settle();
|
||||
pane._onLiveOutput('held');
|
||||
pane.destroy();
|
||||
response.resolve(jsonResponse(rowsOf(100)));
|
||||
held.release(rowsOf(100));
|
||||
await settle();
|
||||
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
@@ -613,10 +664,13 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
||||
|
||||
it('a pull whose request is aborted (the deadline) frees the pane', async () => {
|
||||
const pane = makePane('shell');
|
||||
fetchMock.mockRejectedValueOnce(new Error('The operation timed out'));
|
||||
const held = headersOnly();
|
||||
fetchMock.mockResolvedValueOnce(held.response);
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
await settle();
|
||||
pane._onLiveOutput('held');
|
||||
held.fail(); // the deadline aborts the body read
|
||||
await settle();
|
||||
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
@@ -698,18 +752,18 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(marker);
|
||||
});
|
||||
|
||||
it('writes the disconnected marker once, after the replay, if the socket closes mid-fetch', async () => {
|
||||
// The close lands while the capture is in flight, so the HTTP pull still
|
||||
it('ends on the disconnected marker if the socket closes mid-fetch, before the queue opens', async () => {
|
||||
// The close lands while the request is in flight, so the HTTP pull still
|
||||
// succeeds (a Codeman restart drops the WS while the tmux session, and so
|
||||
// the pull, survives) and the replay that follows is what the marker must
|
||||
// end up below.
|
||||
// the pull, survives). The marker waits for the load to finish, then goes
|
||||
// below the replay.
|
||||
const pane = makePane('shell');
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
|
||||
const pull = pane._pullHistory();
|
||||
pane._onSocketClosed(); // the close arrives mid-fetch, before the response
|
||||
expect(pane.terminal.write).not.toHaveBeenCalled();
|
||||
pane._onSocketClosed();
|
||||
expect(pane.terminal.write.mock.calls.map((c) => c[0]).filter(isMarker)).toHaveLength(0);
|
||||
response.resolve(jsonResponse(rowsOf(100)));
|
||||
await pull;
|
||||
|
||||
@@ -718,25 +772,105 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
||||
expect(isMarker(writes.at(-1))).toBe(true);
|
||||
});
|
||||
|
||||
it('a close mid-fetch that ends without a replay still writes exactly one marker', async () => {
|
||||
const pane = makePane('shell');
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
|
||||
const pull = pane._pullHistory();
|
||||
pane._onSocketClosed();
|
||||
response.resolve(jsonResponse(rowsOf(30))); // already held in full: no replay
|
||||
await pull;
|
||||
|
||||
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
|
||||
expect(writes.filter(isMarker)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a close during a refresh load lands the marker below the replay, not above it', async () => {
|
||||
// A {t:'r'} refresh clears and refetches; a close mid-fetch used to write
|
||||
// the marker at once, and the replay then landed underneath it.
|
||||
const pane = makePane('shell');
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
|
||||
pane._refreshBuffer();
|
||||
pane._onSocketClosed();
|
||||
response.resolve(jsonResponse('refreshed'));
|
||||
await settle();
|
||||
|
||||
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
|
||||
expect(writes.indexOf('refreshed')).toBeLessThan(writes.findIndex(isMarker));
|
||||
expect(isMarker(writes.at(-1))).toBe(true);
|
||||
expect(writes.filter(isMarker)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('back-to-back refreshes on a closed socket leave exactly one marker, at the end', async () => {
|
||||
// R1's finally runs the trailing refresh R2, so R1 leaves the owed marker to
|
||||
// R2 instead of stamping it: in real xterm R1's write would still be queued
|
||||
// when R2's synchronous clear() runs, and would land above R2's replay. The
|
||||
// write mock records every stamp whatever clear() does, so counting marker
|
||||
// writes pins that R1 never stamps (the async-parse test below shows why).
|
||||
const pane = makePane('shell');
|
||||
pane._wsClosed = true;
|
||||
const first = deferred<ReturnType<typeof jsonResponse>>();
|
||||
const second = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
|
||||
|
||||
pane._refreshBuffer(); // R1
|
||||
pane._refreshBuffer(); // coalesced into the trailing R2
|
||||
first.resolve(jsonResponse('first'));
|
||||
await settle();
|
||||
// R1 settled its marker, then R2 cleared and is still fetching.
|
||||
expect(pane.terminal.clear).toHaveBeenCalledTimes(2);
|
||||
second.resolve(jsonResponse('second'));
|
||||
await settle();
|
||||
|
||||
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
|
||||
expect(writes.at(-1)).toSatisfy(isMarker);
|
||||
expect(writes.lastIndexOf('second')).toBe(writes.length - 2);
|
||||
expect(writes.filter(isMarker)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('the pull gives the request the long budget and the body read the short one', async () => {
|
||||
const pane = makePane('shell');
|
||||
const held = headersOnly();
|
||||
fetchMock.mockResolvedValueOnce(held.response);
|
||||
|
||||
const pull = pane._pullHistory();
|
||||
expect(deadlines).toHaveLength(1);
|
||||
expect(deadlines[0].ms).toBe(45_000);
|
||||
await settle(); // headers landed
|
||||
expect(deadlines).toHaveLength(2);
|
||||
expect(deadlines[0].cleared).toBe(true);
|
||||
expect(deadlines[1].ms).toBe(10_000);
|
||||
|
||||
held.release(rowsOf(30));
|
||||
await pull;
|
||||
expect(deadlines[1].cleared).toBe(true); // nothing left to abort a settled pull
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a skip', 40, () => fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(30)))],
|
||||
['a downgrade', 500, () => fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(5)))],
|
||||
['a failed fetch', 40, () => fetchMock.mockRejectedValueOnce(new Error('offline'))],
|
||||
['a skip', 40, (h: ReturnType<typeof headersOnly>) => h.release(rowsOf(30))],
|
||||
['a downgrade', 500, (h: ReturnType<typeof headersOnly>) => h.release(rowsOf(5))],
|
||||
['a failed body read', 40, (h: ReturnType<typeof headersOnly>) => h.fail()],
|
||||
])(
|
||||
'a close mid-fetch that ends in %s writes the marker last, after the held frames',
|
||||
async (_label, rowsHeld, mockFetch) => {
|
||||
'a close with the queue open that ends in %s writes the marker last, after the held frames',
|
||||
async (_label, rowsHeld, finish) => {
|
||||
// No replay ever runs here, so nothing would wipe a marker written at the
|
||||
// close; written straight away it sat ABOVE the output the pull was still
|
||||
// holding, which the finally block then flushed underneath it.
|
||||
const pane = makePane('shell');
|
||||
pane.terminal.buffer.active.length = rowsHeld;
|
||||
mockFetch();
|
||||
const held = headersOnly();
|
||||
fetchMock.mockResolvedValueOnce(held.response);
|
||||
|
||||
const pull = pane._pullHistory();
|
||||
await settle(); // the response landed: the queue is open
|
||||
pane._onLiveOutput('frame-A');
|
||||
pane._onLiveOutput('frame-B');
|
||||
pane._onSocketClosed();
|
||||
expect(pane.terminal.write).not.toHaveBeenCalled();
|
||||
finish(held);
|
||||
await pull;
|
||||
|
||||
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
|
||||
@@ -771,6 +905,96 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
||||
expect(isMarker(writes.at(-1))).toBe(true);
|
||||
});
|
||||
|
||||
it('a refresh queued behind a pull on a closed socket does not wipe the marker', async () => {
|
||||
// The refresh's clear() runs after the pull's finally block has written the
|
||||
// marker, so without a re-stamp the dead pane would look current again.
|
||||
const pane = makePane('shell');
|
||||
const held = headersOnly();
|
||||
fetchMock.mockResolvedValueOnce(held.response).mockResolvedValueOnce(jsonResponse('refreshed'));
|
||||
|
||||
const pull = pane._pullHistory();
|
||||
await settle();
|
||||
pane._refreshBuffer(); // coalesced into one trailing re-run
|
||||
pane._onSocketClosed(); // deferred: the queue is open
|
||||
held.release(rowsOf(30)); // no replay
|
||||
await pull;
|
||||
await settle();
|
||||
|
||||
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
|
||||
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
|
||||
expect(writes).toContain('refreshed');
|
||||
expect(isMarker(writes.at(-1))).toBe(true);
|
||||
expect(writes.lastIndexOf('refreshed')).toBeLessThan(writes.length - 1);
|
||||
// The pull left the owed marker to the refresh rather than stamping it too.
|
||||
expect(writes.filter(isMarker)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'back-to-back refreshes',
|
||||
async (pane: PaneUnderTest) => {
|
||||
pane._wsClosed = true;
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse('first')).mockResolvedValueOnce(jsonResponse('second'));
|
||||
pane._refreshBuffer();
|
||||
pane._refreshBuffer(); // coalesced into the trailing re-run
|
||||
},
|
||||
],
|
||||
[
|
||||
'a pull with a queued refresh and a close mid-pull',
|
||||
async (pane: PaneUnderTest) => {
|
||||
const held = headersOnly();
|
||||
fetchMock.mockResolvedValueOnce(held.response).mockResolvedValueOnce(jsonResponse('second'));
|
||||
void pane._pullHistory();
|
||||
await settle(); // the response landed: the queue is open
|
||||
pane._refreshBuffer(); // coalesced into the trailing re-run
|
||||
pane._onSocketClosed();
|
||||
held.release(rowsOf(30)); // no replay
|
||||
},
|
||||
],
|
||||
])('with xterm parsing writes on a later tick, %s leave one marker on screen, last', async (_label, drive) => {
|
||||
// Real xterm queues write() and parses it on a later tick (WriteBuffer's
|
||||
// setTimeout), while clear() rewrites the buffer at once. The default fake
|
||||
// applies writes synchronously and so cannot show a marker overtaken by a
|
||||
// trailing refresh's clear(): parsed after it, that marker sat above the
|
||||
// refresh's replay as a second, stale copy.
|
||||
const pane = makePane('shell');
|
||||
const screen: string[] = [];
|
||||
const pending: Array<{ data: string; done?: () => void }> = [];
|
||||
pane.terminal.write = vi.fn((data: string, done?: () => void) => {
|
||||
if (pending.length === 0) {
|
||||
setTimeout(() => {
|
||||
for (const entry of pending.splice(0)) {
|
||||
if (entry.data === '\x1bc') screen.length = 0;
|
||||
else if (entry.data) screen.push(entry.data);
|
||||
entry.done?.();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
pending.push({ data, done });
|
||||
});
|
||||
pane.terminal.clear = vi.fn(() => {
|
||||
screen.length = 0;
|
||||
});
|
||||
|
||||
await drive(pane);
|
||||
for (let i = 0; i < 5; i++) await settle();
|
||||
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
expect(screen.filter(isMarker)).toHaveLength(1);
|
||||
expect(screen.at(-1)).toSatisfy(isMarker);
|
||||
expect(screen.indexOf('second')).toBe(screen.length - 2);
|
||||
});
|
||||
|
||||
it('a refresh on an open socket does not stamp a marker', async () => {
|
||||
const pane = makePane('shell');
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse('refreshed'));
|
||||
|
||||
pane._refreshBuffer();
|
||||
await settle();
|
||||
|
||||
expect(pane.terminal.write.mock.calls.map((c) => c[0]).filter(isMarker)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not re-stamp the marker when the socket is still open', async () => {
|
||||
const pane = makePane('shell');
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100)));
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* @fileoverview Real-Chromium coverage for tab-row activation and the grouped rail tree.
|
||||
*
|
||||
* A tab row is one activation target: its status dot, mode chip, name and unused
|
||||
* padding all select it, while its own controls (gear, detach, close, the rail's
|
||||
* overflow button, a web tab's gear and close) run only their own action. The
|
||||
* controls must also keep a stable hit target: revealing one on hover may not
|
||||
* slide its neighbours out from under a pointer already aiming at them.
|
||||
*
|
||||
* DOM emulation cannot answer either question (hit testing, hover reveal and
|
||||
* layout are Chromium's), which is why this runs in a real browser. The shipping
|
||||
* app.js, webview-tabs.js, terminal-ui.js (for the touch keyboard dismissal) and
|
||||
* styles.css are loaded into a page; the CodemanApp instance gets stub actions
|
||||
* that only record what ran.
|
||||
*
|
||||
* Port: none (page.setContent, no server).
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
|
||||
const publicDir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const read = (name: string) => readFileSync(resolve(publicDir, name), 'utf8');
|
||||
|
||||
describe('tab row activation in Chromium', () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1 });
|
||||
// A real origin, so localStorage (per-device collapse) works as it does in the app.
|
||||
await page.route('http://codeman.test/', (route) =>
|
||||
route.fulfill({ contentType: 'text/html', body: '<!doctype html><html><body></body></html>' })
|
||||
);
|
||||
await page.goto('http://codeman.test/');
|
||||
await page.setContent(`<!doctype html>
|
||||
<html data-tab-orientation="horizontal" data-tab-rail-sort="manual">
|
||||
<head><style>${read('styles.css')}</style></head>
|
||||
<body>
|
||||
<header class="header"><div id="sessionTabsHost"></div></header>
|
||||
<main class="main" style="width:100%;height:700px">
|
||||
<aside class="tab-rail" id="tabRail" aria-label="Session navigation"></aside>
|
||||
</main>
|
||||
</body>
|
||||
</html>`);
|
||||
await page.addScriptTag({
|
||||
content:
|
||||
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
|
||||
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
|
||||
read('constants.js') +
|
||||
'\n' +
|
||||
read('tab-layout-browser.js') +
|
||||
'\n' +
|
||||
read('app.js') +
|
||||
'\nwindow.CodemanApp = CodemanApp; window.__setApp = (value) => { app = value; };',
|
||||
});
|
||||
await page.addScriptTag({ content: read('webview-tabs.js') });
|
||||
await page.addScriptTag({ content: read('terminal-ui.js') });
|
||||
await page.evaluate(() => {
|
||||
const w = window as any;
|
||||
const app = Object.create(w.CodemanApp.prototype);
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
app.sessions = new Map([
|
||||
['one', { id: 'one', name: 'One', status: 'idle', mode: 'shell' }],
|
||||
['two', { id: 'two', name: 'Two', status: 'idle' }],
|
||||
]);
|
||||
app.sessionOrder = ['one', 'two'];
|
||||
app.webviews = new Map([['web', { id: 'web', name: 'Web', url: 'https://example.test', icon: 'W' }]]);
|
||||
app.webviewOrder = ['web'];
|
||||
app.activeSessionId = 'one';
|
||||
app.activeWebviewId = null;
|
||||
app.tabLayout = null;
|
||||
app.collapsedTabGroupIds = new Set();
|
||||
app._hiddenTabGroupByRef = new Map();
|
||||
app._lastTabGroupStructureKey = null;
|
||||
app._inlineRenameActive = false;
|
||||
app.tabAlerts = new Map();
|
||||
app.terminalLoadStates = new Map();
|
||||
app.minimizedSubagents = new Map();
|
||||
app.hasTabDetachOverride = () => true;
|
||||
app.renderSubagentTabBadge = () => '';
|
||||
app.cancelHideSubagentDropdown = () => undefined;
|
||||
app.updateTabOverflowMode = () => undefined;
|
||||
app.updateConnectionLines = () => undefined;
|
||||
app._applyTabEntrances = () => undefined;
|
||||
app._scrollActiveTabIntoView = () => undefined;
|
||||
app.applySidebarFilter = () => undefined;
|
||||
app.isSessionSidebarActive = () => false;
|
||||
app._startSidebarRichClock = () => undefined;
|
||||
app._stopSidebarRichClock = () => undefined;
|
||||
const record = (key: string, value: string) => () => {
|
||||
w[key] = value;
|
||||
};
|
||||
app.selectSession = (id: string) => {
|
||||
w.__activation = `session:${id}`;
|
||||
};
|
||||
app.openWebview = (id: string) => {
|
||||
w.__activation = `webview:${id}`;
|
||||
};
|
||||
app.openSessionOptions = record('__action', 'settings');
|
||||
app.detachSession = record('__action', 'detach');
|
||||
app.requestCloseSession = record('__action', 'close');
|
||||
app.openTabRailActionMenu = record('__action', 'overflow');
|
||||
app.showWebviewModal = record('__action', 'web-settings');
|
||||
app.closeWebviewTab = record('__action', 'web-close');
|
||||
w.__setApp(app);
|
||||
w.__app = app;
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => browser.close());
|
||||
|
||||
/** Render the strip/rail in a given shape, from scratch. */
|
||||
async function render(orientation: 'horizontal' | 'vertical', grouped = false) {
|
||||
await page.evaluate(
|
||||
({ orientation, grouped }) => {
|
||||
const w = window as any;
|
||||
document.documentElement.setAttribute('data-tab-orientation', orientation);
|
||||
const host =
|
||||
orientation === 'vertical'
|
||||
? document.getElementById('tabRail')!
|
||||
: document.getElementById('sessionTabsHost')!;
|
||||
document.getElementById('sessionTabs')?.remove();
|
||||
host.insertAdjacentHTML(
|
||||
'afterbegin',
|
||||
'<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs"></div>'
|
||||
);
|
||||
w.__app._tabKeydownHandler = null;
|
||||
w.__app.activeSessionId = 'one';
|
||||
w.__app.activeWebviewId = null;
|
||||
w.__app.tabLayout = grouped
|
||||
? {
|
||||
version: 1,
|
||||
groups: [
|
||||
{
|
||||
id: 'g',
|
||||
name: 'Group',
|
||||
refs: [
|
||||
{ kind: 'session', id: 'one' },
|
||||
{ kind: 'webview', id: 'web' },
|
||||
],
|
||||
},
|
||||
],
|
||||
ungrouped: [{ kind: 'session', id: 'two' }],
|
||||
}
|
||||
: null;
|
||||
w.__app._fullRenderSessionTabs();
|
||||
w.__activation = null;
|
||||
w.__action = null;
|
||||
},
|
||||
{ orientation, grouped }
|
||||
);
|
||||
await page.mouse.move(1, 1);
|
||||
}
|
||||
|
||||
const result = () =>
|
||||
page.evaluate(() => ({ activation: (window as any).__activation, action: (window as any).__action }));
|
||||
const reset = () => page.evaluate(() => ((window as any).__activation = (window as any).__action = null));
|
||||
|
||||
for (const [label, orientation, grouped] of [
|
||||
['horizontal strip', 'horizontal', false],
|
||||
['flat vertical rail', 'vertical', false],
|
||||
['grouped vertical rail', 'vertical', true],
|
||||
] as const) {
|
||||
it(`activates a row from its status, mode, name and padding (${label})`, async () => {
|
||||
await render(orientation, grouped);
|
||||
for (const [selector, expected] of [
|
||||
['[data-id="two"] .tab-status', 'session:two'],
|
||||
['[data-id="two"] .tab-name', 'session:two'],
|
||||
['[data-id="one"] .tab-mode', 'session:one'],
|
||||
['[data-webview-id="web"] .tab-name', 'webview:web'],
|
||||
] as const) {
|
||||
await reset();
|
||||
await page.locator(selector).click();
|
||||
expect((await result()).activation, selector).toBe(expected);
|
||||
}
|
||||
for (const [selector, expected] of [
|
||||
['[data-id="two"]', 'session:two'],
|
||||
['[data-webview-id="web"]', 'webview:web'],
|
||||
] as const) {
|
||||
await reset();
|
||||
const box = (await page.locator(selector).boundingBox())!;
|
||||
await page.mouse.click(box.x + 2, box.y + box.height / 2);
|
||||
expect((await result()).activation, `${selector} leading padding`).toBe(expected);
|
||||
// The cursor advertises the whole row: a pointer, or the grab hand where
|
||||
// the row is also a drag-reorder handle (the flat lists).
|
||||
const { cursor, draggable } = await page
|
||||
.locator(selector)
|
||||
.evaluate((el) => ({ cursor: getComputedStyle(el).cursor, draggable: el.getAttribute('draggable') }));
|
||||
expect(cursor, selector).toBe(draggable === 'true' ? 'grab' : 'pointer');
|
||||
}
|
||||
});
|
||||
|
||||
it(`runs a control's own action without selecting the row (${label})`, async () => {
|
||||
await render(orientation, grouped);
|
||||
for (const [selector, expected] of [
|
||||
['[data-id="one"] .tab-gear', 'settings'],
|
||||
['[data-id="one"] .tab-detach', 'detach'],
|
||||
['[data-id="one"] .tab-close', 'close'],
|
||||
] as const) {
|
||||
await reset();
|
||||
const control = page.locator(selector);
|
||||
expect(await control.isVisible(), selector).toBe(true);
|
||||
expect(await control.evaluate((el) => getComputedStyle(el).cursor), selector).toBe('pointer');
|
||||
await control.click();
|
||||
expect(await result(), selector).toEqual({ activation: null, action: expected });
|
||||
}
|
||||
if (orientation === 'vertical') {
|
||||
await reset();
|
||||
await page.locator('[data-id="one"] .tab-more').click();
|
||||
expect(await result()).toEqual({ activation: null, action: 'overflow' });
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
(window as any).__app.activeWebviewId = 'web';
|
||||
(window as any).__app._fullRenderSessionTabs();
|
||||
});
|
||||
for (const [selector, expected] of [
|
||||
['[data-webview-id="web"] .tab-gear', 'web-settings'],
|
||||
['[data-webview-id="web"] .tab-close', 'web-close'],
|
||||
] as const) {
|
||||
await reset();
|
||||
await page.locator(selector).click();
|
||||
expect(await result(), selector).toEqual({ activation: null, action: expected });
|
||||
}
|
||||
});
|
||||
|
||||
it(`keeps action controls anchored under the pointer when a row is hovered (${label})`, async () => {
|
||||
// Revealing a control on hover must not reflow its row: a button that
|
||||
// toggled `display` on hover once slid the gear 28px left, so the click
|
||||
// aimed at the gear landed on the close button instead. Assert the
|
||||
// geometry, not a CSS property, so any reflow-on-hover rewrite fails here.
|
||||
await render(orientation, grouped);
|
||||
for (const selector of ['[data-id="one"] .tab-gear', '[data-id="one"] .tab-close']) {
|
||||
await page.mouse.move(1, 1);
|
||||
const control = page.locator(selector);
|
||||
const before = (await control.boundingBox())!;
|
||||
const aim = { x: Math.round(before.x + before.width / 2), y: Math.round(before.y + before.height / 2) };
|
||||
await page.mouse.move(aim.x, aim.y);
|
||||
await page.waitForTimeout(250);
|
||||
const after = (await control.boundingBox())!;
|
||||
expect(Math.abs(after.x - before.x), selector).toBeLessThanOrEqual(4);
|
||||
const stillOnTarget = await page.evaluate(
|
||||
({ point, selector }) => !!document.elementFromPoint(point.x, point.y)?.closest(selector),
|
||||
{ point: aim, selector }
|
||||
);
|
||||
expect(stillOnTarget, selector).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('toggles a group from its header without selecting anything', async () => {
|
||||
await render('vertical', true);
|
||||
const header = page.locator('[data-tab-group-header="g"]');
|
||||
await header.click();
|
||||
expect(await result()).toEqual({ activation: null, action: null });
|
||||
expect(await header.getAttribute('aria-expanded')).toBe('false');
|
||||
await page.locator('[data-tab-group-header="g"]').click();
|
||||
expect(await page.locator('[data-tab-group-header="g"]').getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('walks the grouped rail tree from the keyboard with one tab stop', async () => {
|
||||
await render('vertical', true);
|
||||
const tree = page.locator('#sessionTabs[role="tree"]');
|
||||
expect(await tree.count()).toBe(1);
|
||||
expect(await page.locator('#sessionTabs [tabindex="0"]').count()).toBe(1);
|
||||
// The roving tab stop starts on the selected row.
|
||||
await page.keyboard.press('Tab');
|
||||
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-id'))).toBe('one');
|
||||
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-tab-group-header'))).toBe('g');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
expect(await page.locator('[data-tab-group-header="g"]').getAttribute('aria-expanded')).toBe('false');
|
||||
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-tab-group-header'))).toBe('g');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
expect(await page.locator('[data-tab-group-header="g"]').getAttribute('aria-expanded')).toBe('true');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-id'))).toBe('one');
|
||||
await page.keyboard.press('End');
|
||||
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-id'))).toBe('two');
|
||||
await page.keyboard.press('Home');
|
||||
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-tab-group-header'))).toBe('g');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-webview-id'))).toBe('web');
|
||||
await reset();
|
||||
await page.keyboard.press('Enter');
|
||||
expect((await result()).activation).toBe('webview:web');
|
||||
expect(await page.locator('#sessionTabs [tabindex="0"]').count()).toBe(1);
|
||||
});
|
||||
|
||||
it('leaves Enter on a focused in-row control to that control in the tree', async () => {
|
||||
await render('vertical', true);
|
||||
// A click focuses the overflow button (the action menu hands focus back the
|
||||
// same way on Escape). Enter there must run the button, not re-select the row.
|
||||
await page.locator('[data-id="one"] .tab-more').click();
|
||||
expect(await page.evaluate(() => document.activeElement?.classList.contains('tab-more'))).toBe(true);
|
||||
await reset();
|
||||
await page.keyboard.press('Enter');
|
||||
expect(await result()).toEqual({ activation: null, action: 'overflow' });
|
||||
await page.keyboard.press('ArrowDown');
|
||||
expect(await page.evaluate(() => document.activeElement?.classList.contains('tab-more'))).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the touch keyboard up when a tree header or unselected row is tapped', async () => {
|
||||
await render('vertical', true);
|
||||
const state = await page.evaluate(() => {
|
||||
const w = window as any;
|
||||
const app = w.__app;
|
||||
const input = document.createElement('textarea');
|
||||
input.className = 'xterm-helper-textarea';
|
||||
document.body.appendChild(input);
|
||||
app._installMobileKeyboardDismiss();
|
||||
// A finger that lifts where it landed (no travel): the handler reads only
|
||||
// `touches`, so a plain Event carrying them stands in for a TouchEvent.
|
||||
const tap = (target: Element) => {
|
||||
const at = (type: string, touches: Array<{ clientX: number; clientY: number }>) => {
|
||||
const ev = new Event(type, { bubbles: true });
|
||||
Object.defineProperty(ev, 'touches', { value: touches });
|
||||
target.dispatchEvent(ev);
|
||||
};
|
||||
at('touchstart', [{ clientX: 10, clientY: 10 }]);
|
||||
at('touchend', []);
|
||||
};
|
||||
const keptAfterTap = (selector: string) => {
|
||||
input.focus();
|
||||
tap(document.querySelector(selector)!);
|
||||
return document.activeElement === input;
|
||||
};
|
||||
const out = {
|
||||
header: keptAfterTap('[data-tab-group-header="g"] .tab-layout-group-name'),
|
||||
unselectedRow: keptAfterTap('[data-webview-id="web"] .tab-name'),
|
||||
inertChrome: false,
|
||||
};
|
||||
// Inert chrome still dismisses, so the handler is live.
|
||||
out.inertChrome = !keptAfterTap('.tab-layout-ungrouped-header');
|
||||
document.removeEventListener('touchstart', app._mobileKeyboardDismissStart);
|
||||
document.removeEventListener('touchmove', app._mobileKeyboardDismissMove);
|
||||
document.removeEventListener('touchend', app._mobileKeyboardDismissHandler);
|
||||
app._mobileKeyboardDismissHandler = null;
|
||||
input.remove();
|
||||
return out;
|
||||
});
|
||||
expect(state).toEqual({ header: true, unselectedRow: true, inertChrome: true });
|
||||
});
|
||||
|
||||
it('rings a collapsed header in the tab alert colour of the row it hides', async () => {
|
||||
await render('vertical', true);
|
||||
const ring = await page.evaluate(() => {
|
||||
const app = (window as any).__app;
|
||||
// "two" is the selection, so collapsing the group hides "one".
|
||||
app.activeSessionId = 'two';
|
||||
app.tabAlerts.set('one', 'action');
|
||||
app.toggleTabGroupCollapsed('g', true);
|
||||
const header = document.querySelector('[data-tab-group-header="g"]')!;
|
||||
const before = getComputedStyle(header, '::before');
|
||||
const out = {
|
||||
hidden: !document.querySelector('[data-id="one"]'),
|
||||
className: header.classList.contains('tab-alert-action'),
|
||||
content: before.content,
|
||||
borderColor: before.borderTopColor,
|
||||
};
|
||||
app.tabAlerts.clear();
|
||||
app.toggleTabGroupCollapsed('g', false);
|
||||
return out;
|
||||
});
|
||||
expect(ring.hidden).toBe(true);
|
||||
expect(ring.className).toBe(true);
|
||||
expect(ring.content).not.toBe('none');
|
||||
// --red, whatever the skin resolves it to: a real colour, not transparent.
|
||||
expect(ring.borderColor).not.toMatch(/rgba\(0, 0, 0, 0\)|transparent/);
|
||||
});
|
||||
|
||||
it('keeps tab semantics and no tree roles without groups', async () => {
|
||||
await render('vertical', false);
|
||||
expect(await page.locator('#sessionTabs').getAttribute('role')).toBe('tablist');
|
||||
expect(
|
||||
await page
|
||||
.locator('#sessionTabs [role="tree"], #sessionTabs [role="treeitem"], #sessionTabs [role="group"]')
|
||||
.count()
|
||||
).toBe(0);
|
||||
expect(await page.locator('#sessionTabs .session-tab[role="tab"]').count()).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,458 @@
|
||||
/**
|
||||
* @fileoverview Browser projection of the owner tab layout (tab-layout-browser.js).
|
||||
*
|
||||
* Pins the pure half of the grouped vertical rail: which live rows land in which
|
||||
* group, what a collapsed group hides (and the one row it must keep showing),
|
||||
* the per-device collapse storage, the grouped markup, and newest-wins loading.
|
||||
*
|
||||
* Port: none (vm-loaded module + static wiring assertions).
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const SOURCE = readFileSync(new URL('../src/web/public/tab-layout-browser.js', import.meta.url), 'utf8');
|
||||
const APP_SOURCE = readFileSync(new URL('../src/web/public/app.js', import.meta.url), 'utf8');
|
||||
const INDEX_SOURCE = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf8');
|
||||
const BUILD_SOURCE = readFileSync(new URL('../scripts/build.mjs', import.meta.url), 'utf8');
|
||||
|
||||
type Ref = { kind: 'session' | 'webview'; id: string };
|
||||
|
||||
function loadHelper() {
|
||||
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||
vm.runInContext(SOURCE, context, { filename: 'tab-layout-browser.js' });
|
||||
return (context.window as any).CodemanTabLayout;
|
||||
}
|
||||
|
||||
const escape = (value: string) =>
|
||||
value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
const layout = (version = 4) => ({
|
||||
version,
|
||||
updatedAt: '2026-08-16T00:00:00.000Z',
|
||||
groups: [
|
||||
{
|
||||
id: 'g1',
|
||||
name: '<Core & Ops>',
|
||||
refs: [
|
||||
{ kind: 'session', id: 's2' },
|
||||
{ kind: 'webview', id: 'w1' },
|
||||
{ kind: 'session', id: 's1' },
|
||||
{ kind: 'webview', id: 'saved-closed' },
|
||||
{ kind: 'session', id: 'gone' },
|
||||
],
|
||||
},
|
||||
{ id: 'g2', name: 'Later', refs: [{ kind: 'session', id: 's3' }] },
|
||||
],
|
||||
ungrouped: [
|
||||
{ kind: 'webview', id: 'w2' },
|
||||
{ kind: 'session', id: 's4' },
|
||||
{ kind: 'session', id: 's2' },
|
||||
],
|
||||
});
|
||||
|
||||
const ids = (refs: Ref[]) => refs.map((ref) => `${ref.kind}:${ref.id}`);
|
||||
|
||||
describe('projection', () => {
|
||||
it('returns null for a layout without groups, so the caller keeps the flat rail', () => {
|
||||
const h = loadHelper();
|
||||
expect(h.project({ version: 1, groups: [], ungrouped: [{ kind: 'session', id: 's1' }] }, {})).toBeNull();
|
||||
expect(h.project(null, {})).toBeNull();
|
||||
expect(h.hasGroups(h.normalizeLayout({ version: 1 }))).toBe(false);
|
||||
});
|
||||
|
||||
it('places stored refs in their groups, skips gone/unopened refs, and deduplicates', () => {
|
||||
const h = loadHelper();
|
||||
const result = h.project(layout(), {
|
||||
liveSessionIds: ['s1', 's2', 's3', 's4'],
|
||||
openWebviewIds: ['w1', 'w2'],
|
||||
});
|
||||
expect(result.sections.map((s: any) => [s.id, ids(s.refs), s.count])).toEqual([
|
||||
['g1', ['session:s2', 'webview:w1', 'session:s1'], 3],
|
||||
['g2', ['session:s3'], 1],
|
||||
[null, ['webview:w2', 'session:s4'], 2],
|
||||
]);
|
||||
expect(new Set(ids(result.visibleRefs)).size).toBe(result.visibleRefs.length);
|
||||
});
|
||||
|
||||
it('appends live sessions and open web tabs the layout has not placed, in caller order', () => {
|
||||
const h = loadHelper();
|
||||
const result = h.project(layout(), {
|
||||
liveSessionIds: ['new-b', 's1', 'new-a'],
|
||||
openWebviewIds: ['w-local'],
|
||||
});
|
||||
expect(ids(result.sections.at(-1).refs)).toEqual(['session:new-b', 'session:new-a', 'webview:w-local']);
|
||||
});
|
||||
|
||||
it('omits an empty ungrouped section', () => {
|
||||
const h = loadHelper();
|
||||
const result = h.project(layout(), { liveSessionIds: ['s1', 's3'], openWebviewIds: [] });
|
||||
expect(result.sections.map((s: any) => s.id)).toEqual(['g1', 'g2']);
|
||||
});
|
||||
|
||||
it('hides a collapsed group but keeps its highlighted row, and records where hidden rows went', () => {
|
||||
const h = loadHelper();
|
||||
const common = {
|
||||
liveSessionIds: ['s1', 's2', 's3', 's4'],
|
||||
openWebviewIds: ['w1', 'w2'],
|
||||
collapsedGroupIds: ['g1'],
|
||||
};
|
||||
const collapsed = h.project(layout(), { ...common, activeSessionId: 's1' });
|
||||
expect(ids(collapsed.sections[0].refs)).toEqual(['session:s1']);
|
||||
expect(collapsed.sections[0].count).toBe(3);
|
||||
expect(collapsed.sections[0].collapsed).toBe(true);
|
||||
expect(collapsed.hiddenTabGroupByRef).toEqual({ 'session:s2': 'g1', 'webview:w1': 'g1' });
|
||||
expect(ids(collapsed.sections[0].hidden)).toEqual(['session:s2', 'webview:w1']);
|
||||
// Every placed row knows its section, shown or hidden (null = Ungrouped).
|
||||
expect(collapsed.sectionByRef).toEqual({
|
||||
'session:s2': 'g1',
|
||||
'webview:w1': 'g1',
|
||||
'session:s1': 'g1',
|
||||
'session:s3': 'g2',
|
||||
'webview:w2': null,
|
||||
'session:s4': null,
|
||||
});
|
||||
|
||||
// An active web tab owns the highlight even while a session stays selected.
|
||||
const web = h.project(layout(), { ...common, activeSessionId: 's1', activeWebviewId: 'w1' });
|
||||
expect(ids(web.sections[0].refs)).toEqual(['webview:w1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('structure key', () => {
|
||||
it('changes with every structural input and ignores everything else', () => {
|
||||
const h = loadHelper();
|
||||
const opts = { liveSessionIds: ['s1', 's2', 's3'], openWebviewIds: [] };
|
||||
const base = h.structureKey(layout(), h.project(layout(), opts), []);
|
||||
expect(h.structureKey(layout(), h.project(layout(), opts), [])).toBe(base);
|
||||
expect(h.structureKey(layout(), h.project(layout(), { ...opts, collapsedGroupIds: ['g1'] }), ['g1'])).not.toBe(
|
||||
base
|
||||
);
|
||||
expect(h.structureKey(layout(), h.project(layout(), { ...opts, liveSessionIds: ['s1', 's2'] }), [])).not.toBe(base);
|
||||
const renamed = { ...layout(), groups: [{ ...layout().groups[0], name: 'Renamed' }, layout().groups[1]] };
|
||||
expect(h.structureKey(renamed, h.project(renamed, opts), [])).not.toBe(base);
|
||||
expect(h.structureKey(layout(), null, [])).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a version bump that moves nothing, so a lifecycle broadcast costs no rebuild', () => {
|
||||
const h = loadHelper();
|
||||
const opts = { liveSessionIds: ['s1', 's2', 's3'], openWebviewIds: [] };
|
||||
expect(h.structureKey(layout(5), h.project(layout(5), opts), [])).toBe(
|
||||
h.structureKey(layout(4), h.project(layout(4), opts), [])
|
||||
);
|
||||
});
|
||||
|
||||
it('changes when a collapse hides a different row, even with equal counts and shown rows', () => {
|
||||
const h = loadHelper();
|
||||
const two = (refs: Ref[][]) => ({
|
||||
version: 1,
|
||||
groups: [
|
||||
{ id: 'a', name: 'A', refs: refs[0] },
|
||||
{ id: 'b', name: 'B', refs: refs[1] },
|
||||
],
|
||||
ungrouped: [],
|
||||
});
|
||||
const s = (id: string): Ref => ({ kind: 'session', id });
|
||||
const opts = { liveSessionIds: ['x', 'y'], openWebviewIds: [], collapsedGroupIds: ['a', 'b'] };
|
||||
const before = two([[s('x')], [s('y')]]);
|
||||
const after = two([[s('y')], [s('x')]]);
|
||||
// Lineage anchors a hidden row to its header, so who hides where is structure.
|
||||
expect(h.structureKey(after, h.project(after, opts), ['a', 'b'])).not.toBe(
|
||||
h.structureKey(before, h.project(before, opts), ['a', 'b'])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collapsed header alerts', () => {
|
||||
it('reports the most urgent alert among the session rows a collapse hides', () => {
|
||||
const h = loadHelper();
|
||||
const alerts = new Map([
|
||||
['s2', 'idle'],
|
||||
['s1', 'action'],
|
||||
['s3', 'idle'],
|
||||
['s4', 'action'],
|
||||
]);
|
||||
const alertOf = (id: string) => alerts.get(id);
|
||||
const common = { liveSessionIds: ['s1', 's2', 's3', 's4'], openWebviewIds: ['w1', 'w2'] };
|
||||
// s1 is the kept selection, so it draws its own ring: only s2 (idle) is behind g1.
|
||||
const kept = h.project(layout(), { ...common, collapsedGroupIds: ['g1', 'g2'], activeSessionId: 's1' });
|
||||
expect(h.hiddenGroupAlerts(kept, alertOf)).toEqual({ g1: 'idle', g2: 'idle' });
|
||||
// With s1 hidden too, the red one wins over the yellow one.
|
||||
const hidden = h.project(layout(), { ...common, collapsedGroupIds: ['g1'], activeSessionId: 's4' });
|
||||
expect(h.hiddenGroupAlerts(hidden, alertOf)).toEqual({ g1: 'action' });
|
||||
// Expanded groups and the ungrouped section never report (their rows are on screen).
|
||||
expect(h.hiddenGroupAlerts(h.project(layout(), common), alertOf)).toEqual({});
|
||||
expect(h.hiddenGroupAlerts(null, alertOf)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-device collapse storage', () => {
|
||||
function storage(initial: string | null = null) {
|
||||
const values = new Map<string, string>();
|
||||
if (initial !== null) values.set('codeman:tab-groups-collapsed', initial);
|
||||
return {
|
||||
getItem: vi.fn((key: string) => values.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, next: string) => {
|
||||
values.set(key, next);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
it('round-trips collapsed group ids', () => {
|
||||
const h = loadHelper();
|
||||
const local = storage();
|
||||
expect(h.saveCollapsedGroupIds(local, ['g2', 'g1', 'g2'])).toEqual({ ids: ['g2', 'g1'], ok: true });
|
||||
expect(h.loadCollapsedGroupIds(local, ['g1', 'g2'])).toEqual({ ids: ['g2', 'g1'], ok: true });
|
||||
});
|
||||
|
||||
it('garbage-collects ids of groups that no longer exist', () => {
|
||||
const h = loadHelper();
|
||||
const local = storage('["gone","g1"]');
|
||||
expect(h.loadCollapsedGroupIds(local, ['g1', 'g2'])).toEqual({ ids: ['g1'], ok: true });
|
||||
expect(local.setItem).toHaveBeenLastCalledWith('codeman:tab-groups-collapsed', '["g1"]');
|
||||
});
|
||||
|
||||
it('reports failure (all-expanded) on unreadable or unwritable storage', () => {
|
||||
const h = loadHelper();
|
||||
const throwing = {
|
||||
getItem: () => {
|
||||
throw new Error('denied');
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error('quota');
|
||||
},
|
||||
};
|
||||
expect(h.loadCollapsedGroupIds(throwing, ['g1'])).toEqual({ ids: [], ok: false });
|
||||
expect(h.saveCollapsedGroupIds(throwing, ['g1'])).toEqual({ ids: [], ok: false });
|
||||
});
|
||||
|
||||
it('reads a malformed stored value as nothing collapsed and repairs it, so collapse keeps working', () => {
|
||||
const h = loadHelper();
|
||||
for (const bad of ['{"g1":true}', 'not json', '"g1"', 'null']) {
|
||||
const local = storage(bad);
|
||||
expect(h.loadCollapsedGroupIds(local, ['g1']), bad).toEqual({ ids: [], ok: true });
|
||||
expect(local.setItem, bad).toHaveBeenLastCalledWith('codeman:tab-groups-collapsed', '[]');
|
||||
expect(h.saveCollapsedGroupIds(local, ['g1']), bad).toEqual({ ids: ['g1'], ok: true });
|
||||
expect(h.loadCollapsedGroupIds(local, ['g1']), bad).toEqual({ ids: ['g1'], ok: true });
|
||||
}
|
||||
// Without a group list to validate against, nothing is written.
|
||||
const untouched = storage('{"g1":true}');
|
||||
expect(h.loadCollapsedGroupIds(untouched)).toEqual({ ids: [], ok: true });
|
||||
expect(untouched.setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('grouped markup', () => {
|
||||
it('renders escaped group sections as a tree skeleton around caller-rendered rows', () => {
|
||||
const h = loadHelper();
|
||||
const projection = h.project(layout(), {
|
||||
liveSessionIds: ['s1', 's2', 's3', 's4'],
|
||||
openWebviewIds: ['w1', 'w2'],
|
||||
collapsedGroupIds: ['g2'],
|
||||
});
|
||||
const html = h.renderProjection(
|
||||
projection,
|
||||
(ref: Ref) => `<div class="row" data-ref="${ref.kind}:${ref.id}"></div>`,
|
||||
escape
|
||||
);
|
||||
const doc = new JSDOM(`<main>${html}</main>`).window.document;
|
||||
expect([...doc.querySelectorAll('.tab-layout-group-name')].map((el) => el.textContent)).toEqual([
|
||||
'<Core & Ops>',
|
||||
'Later',
|
||||
'Ungrouped',
|
||||
]);
|
||||
expect(doc.querySelectorAll('.tab-layout-group-name *')).toHaveLength(0);
|
||||
expect(doc.querySelector('.tab-layout-group-name')?.hasAttribute('data-i18n-skip')).toBe(true);
|
||||
// Sections are layout only; the caller's container is the tree.
|
||||
expect([...doc.querySelectorAll('section')].every((el) => el.getAttribute('role') === 'presentation')).toBe(true);
|
||||
|
||||
// A named header is a level-1 treeitem that toggles and OWNS its rows' group.
|
||||
const g1 = doc.querySelector<HTMLElement>('[data-tab-group-header="g1"]')!;
|
||||
expect(g1.getAttribute('role')).toBe('treeitem');
|
||||
expect(g1.getAttribute('tabindex')).toBe('-1');
|
||||
expect(g1.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(g1.getAttribute('onclick')).toBe('app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)');
|
||||
const owned = doc.getElementById(g1.getAttribute('aria-owns')!)!;
|
||||
expect(owned.getAttribute('role')).toBe('group');
|
||||
expect(doc.getElementById(owned.getAttribute('aria-labelledby')!)?.textContent).toBe('<Core & Ops>');
|
||||
expect(owned.querySelectorAll('.row')).toHaveLength(3);
|
||||
// No interactive element nested inside a treeitem.
|
||||
expect(g1.querySelectorAll('button, [tabindex]')).toHaveLength(0);
|
||||
|
||||
// A collapsed header owns nothing, so its kept row cannot read as the child of a closed node.
|
||||
const g2 = doc.querySelector<HTMLElement>('[data-tab-group-header="g2"]')!;
|
||||
expect(g2.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(g2.hasAttribute('aria-owns')).toBe(false);
|
||||
expect(g2.closest('section')!.querySelector('.tab-layout-group-refs')!.getAttribute('role')).toBe('presentation');
|
||||
|
||||
// The ungrouped heading is a visual divider: nothing to collapse, nothing to announce.
|
||||
const ungrouped = doc.querySelector('.tab-layout-ungrouped-header')!;
|
||||
expect(ungrouped.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(ungrouped.hasAttribute('role')).toBe(false);
|
||||
expect(ungrouped.closest('section')!.querySelector('.tab-layout-group-refs')!.getAttribute('role')).toBe(
|
||||
'presentation'
|
||||
);
|
||||
expect([...doc.querySelectorAll<HTMLElement>('.row')].map((el) => el.dataset.ref)).toEqual(
|
||||
ids(projection.visibleRefs)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty groups', () => {
|
||||
it('renders a group with no open rows as a tree leaf, expanded or collapsed', () => {
|
||||
const h = loadHelper();
|
||||
for (const collapsedGroupIds of [[], ['g2']]) {
|
||||
const projection = h.project(layout(), { liveSessionIds: ['s1', 's2'], openWebviewIds: [], collapsedGroupIds });
|
||||
expect(projection.sections[1]).toMatchObject({ id: 'g2', count: 0, refs: [] });
|
||||
const html = h.renderProjection(projection, () => '', escape);
|
||||
const doc = new JSDOM(`<main>${html}</main>`).window.document;
|
||||
const header = doc.querySelector('[data-tab-group-header="g2"]')!;
|
||||
expect(header.getAttribute('role')).toBe('treeitem');
|
||||
expect(header.hasAttribute('aria-expanded')).toBe(false);
|
||||
expect(header.hasAttribute('aria-owns')).toBe(false);
|
||||
expect(header.closest('section')!.querySelector('.tab-layout-group-refs')!.getAttribute('role')).toBe(
|
||||
'presentation'
|
||||
);
|
||||
// The populated group is unaffected.
|
||||
expect(doc.querySelector('[data-tab-group-header="g1"]')!.getAttribute('aria-expanded')).toBe('true');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('load coordination', () => {
|
||||
it('applies only the newest response when loads overlap', async () => {
|
||||
const h = loadHelper();
|
||||
const pending: Array<(value: unknown) => void> = [];
|
||||
const applied: number[] = [];
|
||||
const coordinator = h.createLoadCoordinator({
|
||||
fetchLayout: () => new Promise((resolve) => pending.push(resolve)),
|
||||
applyLayout: (value: any) => applied.push(value.version),
|
||||
applyFallback: vi.fn(),
|
||||
scheduleRetry: vi.fn(),
|
||||
});
|
||||
const first = coordinator.load();
|
||||
const second = coordinator.load();
|
||||
pending[1](layout(2));
|
||||
await second;
|
||||
pending[0](layout(1));
|
||||
await first;
|
||||
expect(applied).toEqual([2]);
|
||||
});
|
||||
|
||||
it('falls back on failure, schedules one retry, and lets a successful retry replace the fallback', async () => {
|
||||
const h = loadHelper();
|
||||
const applyLayout = vi.fn();
|
||||
const applyFallback = vi.fn();
|
||||
let retry: (() => Promise<boolean>) | undefined;
|
||||
let attempt = 0;
|
||||
const coordinator = h.createLoadCoordinator({
|
||||
fetchLayout: async () => {
|
||||
attempt++;
|
||||
if (attempt === 1) throw new Error('offline');
|
||||
return layout(7);
|
||||
},
|
||||
applyLayout,
|
||||
applyFallback,
|
||||
scheduleRetry: (fn: () => Promise<boolean>) => {
|
||||
retry = fn;
|
||||
return 42;
|
||||
},
|
||||
cancelRetry: vi.fn(),
|
||||
});
|
||||
await coordinator.load();
|
||||
expect(applyFallback).toHaveBeenCalledTimes(1);
|
||||
expect(applyLayout).not.toHaveBeenCalled();
|
||||
await retry!();
|
||||
expect(applyLayout).toHaveBeenCalledWith(layout(7));
|
||||
});
|
||||
|
||||
it('backs off failed retries, stops after the cap, and starts over after a success', async () => {
|
||||
const h = loadHelper();
|
||||
const delays: number[] = [];
|
||||
let pending: (() => Promise<boolean>) | null = null;
|
||||
let failing = true;
|
||||
const applyFallback = vi.fn();
|
||||
const coordinator = h.createLoadCoordinator({
|
||||
fetchLayout: async () => {
|
||||
if (failing) throw new Error('offline');
|
||||
return layout(1);
|
||||
},
|
||||
applyLayout: vi.fn(),
|
||||
applyFallback,
|
||||
retryDelayMs: 5000,
|
||||
maxRetryDelayMs: 30000,
|
||||
maxRetries: 4,
|
||||
scheduleRetry: (fn: () => Promise<boolean>, delay: number) => {
|
||||
delays.push(delay);
|
||||
pending = fn;
|
||||
return delays.length;
|
||||
},
|
||||
cancelRetry: vi.fn(),
|
||||
});
|
||||
await coordinator.load();
|
||||
// Bounded drain: an uncapped retry must fail here, not spin forever (the
|
||||
// loop only awaits microtasks, so vitest's own timeout could never fire).
|
||||
for (let drained = 0; pending && drained < 10; drained++) {
|
||||
const next: () => Promise<boolean> = pending;
|
||||
pending = null;
|
||||
await next();
|
||||
}
|
||||
// Four retries (5 s doubling, capped at 30 s), then nothing more is scheduled.
|
||||
expect(delays).toEqual([5000, 10000, 20000, 30000]);
|
||||
expect(applyFallback).toHaveBeenCalledTimes(5);
|
||||
|
||||
// An outside load (SSE init, tab:layoutChanged) still tries, and a success
|
||||
// resets the count, so the next outage gets the full schedule again.
|
||||
failing = false;
|
||||
expect(await coordinator.load()).toBe(true);
|
||||
failing = true;
|
||||
await coordinator.load();
|
||||
expect(delays.at(-1)).toBe(5000);
|
||||
coordinator.dispose();
|
||||
});
|
||||
|
||||
it('cancels a pending retry and ignores in-flight results after dispose', async () => {
|
||||
const h = loadHelper();
|
||||
let resolve!: (value: unknown) => void;
|
||||
const applyLayout = vi.fn();
|
||||
const cancelRetry = vi.fn();
|
||||
let calls = 0;
|
||||
const coordinator = h.createLoadCoordinator({
|
||||
fetchLayout: () => {
|
||||
calls++;
|
||||
if (calls === 1) return Promise.reject(new Error('offline'));
|
||||
return new Promise((r) => (resolve = r));
|
||||
},
|
||||
applyLayout,
|
||||
applyFallback: vi.fn(),
|
||||
scheduleRetry: () => 7,
|
||||
cancelRetry,
|
||||
});
|
||||
await coordinator.load();
|
||||
const second = coordinator.load();
|
||||
expect(cancelRetry).toHaveBeenCalledWith(7);
|
||||
coordinator.dispose();
|
||||
resolve(layout(3));
|
||||
await second;
|
||||
expect(applyLayout).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('browser wiring', () => {
|
||||
it('loads the helper before app.js, ships it through the build, and wires GET + SSE', () => {
|
||||
expect(INDEX_SOURCE.indexOf('<script defer src="tab-layout-browser.js"')).toBeGreaterThan(-1);
|
||||
expect(INDEX_SOURCE.indexOf('<script defer src="tab-layout-browser.js"')).toBeLessThan(
|
||||
INDEX_SOURCE.indexOf('<script defer src="app.js"')
|
||||
);
|
||||
expect(BUILD_SOURCE).toContain("'tab-layout-browser.js'");
|
||||
expect(APP_SOURCE).toContain("this._apiJson('/api/tab-layout')");
|
||||
expect(APP_SOURCE).toContain("[SSE_EVENTS.TAB_LAYOUT_CHANGED, '_onTabLayoutChanged']");
|
||||
});
|
||||
|
||||
it('never writes the layout from the browser in this slice', () => {
|
||||
expect(APP_SOURCE).not.toMatch(/['"`]PUT['"`][^\n]*tab-layout|tab-layout[^\n]*['"`]PUT['"`]/);
|
||||
expect(SOURCE).not.toContain('fetch(');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,818 @@
|
||||
/**
|
||||
* @fileoverview The grouped vertical rail, driven through the shipping CodemanApp.
|
||||
*
|
||||
* Covers the app.js half of the owner-tab-layout frontend: the rail renders the
|
||||
* owner's groups only when it is vertical AND there is at least one group (every
|
||||
* other case must be byte-for-byte the flat rail), collapse is per-device and
|
||||
* keeps the active row, a structural change escapes the incremental patch path,
|
||||
* drag-reorder and cross-group Ctrl+Shift moves are withheld, a collapsed
|
||||
* header carries the alert it hides, layout reads rebuild only on a structural
|
||||
* change and back off when they fail, lineage arcs to a collapse-hidden session
|
||||
* anchor to its group header, and the grouped rail (only) is an ARIA tree with
|
||||
* one roving tab stop, a tree keyboard model and focus restored across rebuilds.
|
||||
*
|
||||
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only'), so
|
||||
* `document`, `localStorage` and `window` below are that window's, not Node's.
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = join(process.cwd(), 'src/web/public');
|
||||
let CodemanApp: { prototype: Record<string, any> };
|
||||
let window: any;
|
||||
let document: Document;
|
||||
let localStorage: Storage;
|
||||
let localStorageDescriptor: PropertyDescriptor | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
||||
url: 'https://localhost/',
|
||||
runScripts: 'outside-only',
|
||||
});
|
||||
// Evaluate after DOMContentLoaded so app.js's own bootstrap (new CodemanApp()
|
||||
// on that event) never runs: each test builds the instance it needs.
|
||||
if (dom.window.document.readyState !== 'complete') {
|
||||
await new Promise((resolve) => dom.window.addEventListener('load', resolve));
|
||||
}
|
||||
window = dom.window;
|
||||
document = window.document;
|
||||
localStorage = window.localStorage;
|
||||
localStorageDescriptor = Object.getOwnPropertyDescriptor(window, 'localStorage');
|
||||
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
|
||||
window.setInterval = () => 0;
|
||||
window.requestAnimationFrame = () => 0;
|
||||
window.CSS = { escape: (value: string) => value };
|
||||
window.eval(
|
||||
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
|
||||
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
|
||||
read('constants.js') +
|
||||
'\n' +
|
||||
read('tab-layout-browser.js') +
|
||||
'\n' +
|
||||
read('app.js') +
|
||||
'\n' +
|
||||
read('webview-tabs.js') +
|
||||
'\n' +
|
||||
read('session-lineage.js') +
|
||||
'\n;window.__GroupedRailCodemanApp = CodemanApp;'
|
||||
);
|
||||
CodemanApp = window.__GroupedRailCodemanApp;
|
||||
});
|
||||
|
||||
const layout = {
|
||||
version: 8,
|
||||
updatedAt: '2026-08-16T00:00:00.000Z',
|
||||
groups: [
|
||||
{
|
||||
id: 'group-x',
|
||||
name: '<Core & Ops>',
|
||||
refs: [
|
||||
{ kind: 'session', id: 's2' },
|
||||
{ kind: 'webview', id: 'w1' },
|
||||
{ kind: 'session', id: 's1' },
|
||||
],
|
||||
},
|
||||
],
|
||||
ungrouped: [{ kind: 'session', id: 's3' }],
|
||||
};
|
||||
|
||||
function makeApp(options: { tabLayout?: unknown } = {}) {
|
||||
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
|
||||
document.documentElement.setAttribute('data-tab-orientation', 'vertical');
|
||||
document.documentElement.dataset.tabRailSort = 'manual';
|
||||
document.body.innerHTML = '<div id="sessionTabs"></div>';
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
app.sessions = new Map([
|
||||
['s1', { id: 's1', name: 'One', status: 'idle' }],
|
||||
['s2', { id: 's2', name: 'Two', status: 'busy' }],
|
||||
['s3', { id: 's3', name: 'Three', status: 'idle' }],
|
||||
]);
|
||||
app.sessionOrder = ['s1', 's2', 's3'];
|
||||
app.webviews = new Map([['w1', { id: 'w1', name: 'Dashboard', url: 'https://example.test' }]]);
|
||||
app.webviewOrder = ['w1'];
|
||||
app.activeSessionId = 's2';
|
||||
app.activeWebviewId = null;
|
||||
app.tabLayout = 'tabLayout' in options ? options.tabLayout : layout;
|
||||
app.collapsedTabGroupIds = new Set();
|
||||
app._hiddenTabGroupByRef = new Map();
|
||||
app._lastTabGroupStructureKey = null;
|
||||
app._tabCollapseStorageFailed = false;
|
||||
app._inlineRenameActive = false;
|
||||
app.tabAlerts = new Map();
|
||||
app.terminalLoadStates = new Map();
|
||||
app.minimizedSubagents = new Map();
|
||||
app.hasTabDetachOverride = () => false;
|
||||
app.renderSubagentTabBadge = () => '';
|
||||
app.cancelHideSubagentDropdown = () => {};
|
||||
app.updateTabOverflowMode = () => {};
|
||||
app.updateConnectionLines = vi.fn();
|
||||
app._applyTabEntrances = () => {};
|
||||
app._scrollActiveTabIntoView = () => {};
|
||||
app._refreshMobileOverviewIfVisible = () => {};
|
||||
app._refreshHomeSessionsIfVisible = () => {};
|
||||
app.applySidebarFilter = () => {};
|
||||
return app;
|
||||
}
|
||||
|
||||
const rowIds = () =>
|
||||
[...document.querySelectorAll<HTMLElement>('#sessionTabs .session-tab')].map(
|
||||
(tab) => tab.dataset.webviewId || tab.dataset.id
|
||||
);
|
||||
const badges = () =>
|
||||
[...document.querySelectorAll<HTMLElement>('#sessionTabs .session-tab')].map(
|
||||
(tab) => tab.querySelector('.tab-number')?.textContent ?? null
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
delete window.__codemanUser;
|
||||
document.documentElement.setAttribute('data-tab-orientation', 'horizontal');
|
||||
document.body.innerHTML = '';
|
||||
localStorage.removeItem('codeman:tab-groups-collapsed');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (localStorageDescriptor) Object.defineProperty(window, 'localStorage', localStorageDescriptor);
|
||||
else delete window.localStorage;
|
||||
});
|
||||
|
||||
describe('flat rail is unchanged without groups', () => {
|
||||
it('renders identical markup for no layout, a failed read and a layout with zero groups', () => {
|
||||
const none = makeApp({ tabLayout: null });
|
||||
none._fullRenderSessionTabs();
|
||||
const flat = document.getElementById('sessionTabs')!.innerHTML;
|
||||
expect(rowIds()).toEqual(['s1', 's2', 's3', 'w1']);
|
||||
|
||||
const empty = makeApp({ tabLayout: { version: 3, groups: [], ungrouped: [{ kind: 'session', id: 's3' }] } });
|
||||
empty._fullRenderSessionTabs();
|
||||
expect(document.getElementById('sessionTabs')!.innerHTML).toBe(flat);
|
||||
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(0);
|
||||
expect(document.getElementById('sessionTabs')!.classList.contains('session-tabs--grouped')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the horizontal strip flat even when the owner has groups', () => {
|
||||
const app = makeApp();
|
||||
document.documentElement.setAttribute('data-tab-orientation', 'horizontal');
|
||||
app._fullRenderSessionTabs();
|
||||
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(0);
|
||||
expect(rowIds()).toEqual(['s1', 's2', 's3', 'w1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('grouped vertical rail', () => {
|
||||
it('draws escaped sections in layout order while badges keep their Alt+N slot', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
expect([...document.querySelectorAll('.tab-layout-group-name')].map((el) => el.textContent)).toEqual([
|
||||
'<Core & Ops>',
|
||||
'Ungrouped',
|
||||
]);
|
||||
expect(document.querySelectorAll('.tab-layout-group-name *')).toHaveLength(0);
|
||||
expect(rowIds()).toEqual(['s2', 'w1', 's1', 's3']);
|
||||
// sessionOrder is s1,s2,s3 and the web tab follows every session: the badge
|
||||
// names the Alt+N key, never the row position.
|
||||
expect(badges()).toEqual(['2', '4', '1', '3']);
|
||||
expect(document.querySelectorAll('.session-tab.active')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('withholds drag-reorder in the grouped rail only', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
const draggable = () =>
|
||||
[...document.querySelectorAll<HTMLElement>('.session-tab[data-id]')].map((t) => t.getAttribute('draggable'));
|
||||
expect(draggable()).toEqual(['false', 'false', 'false']);
|
||||
|
||||
const flat = makeApp({ tabLayout: null });
|
||||
flat._fullRenderSessionTabs();
|
||||
expect(draggable()).toEqual(['true', 'true', 'true']);
|
||||
});
|
||||
|
||||
it('collapses from the header button, persists per device, and keeps the active row', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
const button = document.querySelector<HTMLElement>('[data-tab-group-header="group-x"]')!;
|
||||
// The inline onclick calls the GLOBAL app with the button's own dataset.
|
||||
expect(button.getAttribute('onclick')).toBe('app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)');
|
||||
app.toggleTabGroupCollapsed(button.dataset.tabGroupHeader);
|
||||
|
||||
expect([...app.collapsedTabGroupIds]).toEqual(['group-x']);
|
||||
expect(localStorage.getItem('codeman:tab-groups-collapsed')).toBe('["group-x"]');
|
||||
const header = document.querySelector('[data-tab-group-header="group-x"]')!;
|
||||
expect(header.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(header.querySelector('.tab-layout-group-count')?.textContent).toBe('3');
|
||||
expect(rowIds()).toEqual(['s2', 's3']);
|
||||
expect(app._hiddenTabGroupByRef.get('session:s1')).toBe('group-x');
|
||||
|
||||
app.toggleTabGroupCollapsed('group-x');
|
||||
expect(rowIds()).toEqual(['s2', 'w1', 's1', 's3']);
|
||||
expect(localStorage.getItem('codeman:tab-groups-collapsed')).toBe('[]');
|
||||
});
|
||||
|
||||
it('reveals a hidden session when it is selected, without expanding its group', () => {
|
||||
const app = makeApp();
|
||||
app.collapsedTabGroupIds = new Set(['group-x']);
|
||||
app._fullRenderSessionTabs();
|
||||
expect(rowIds()).toEqual(['s2', 's3']);
|
||||
|
||||
app.activeSessionId = 's1';
|
||||
app._updateActiveTabImmediate('s1');
|
||||
|
||||
expect([...app.collapsedTabGroupIds]).toEqual(['group-x']);
|
||||
expect(rowIds()).toEqual(['s1', 's3']);
|
||||
expect(badges()).toEqual(['1', '3']);
|
||||
expect(document.querySelector('.session-tab.active')?.getAttribute('data-id')).toBe('s1');
|
||||
});
|
||||
|
||||
it('patches rows in place while the structure holds, and rebuilds when it changes', () => {
|
||||
const app = makeApp();
|
||||
app.collapsedTabGroupIds = new Set(['group-x']);
|
||||
app._fullRenderSessionTabs();
|
||||
const full = vi.spyOn(app, '_fullRenderSessionTabs');
|
||||
|
||||
app.sessions.get('s3').status = 'busy';
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(full).not.toHaveBeenCalled();
|
||||
expect(document.querySelector('[data-id="s3"] .tab-status')?.className).toBe('tab-status busy');
|
||||
|
||||
// Same rows on screen, different grouping: s3 moves into group-x. The id
|
||||
// sets still match, so only the structure key can catch this.
|
||||
app.collapsedTabGroupIds = new Set();
|
||||
app._fullRenderSessionTabs();
|
||||
full.mockClear();
|
||||
app.tabLayout = {
|
||||
...layout,
|
||||
version: 9,
|
||||
groups: [{ ...layout.groups[0], refs: [...layout.groups[0].refs, { kind: 'session', id: 's3' }] }],
|
||||
ungrouped: [],
|
||||
};
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(full).toHaveBeenCalledTimes(1);
|
||||
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(1);
|
||||
expect(rowIds()).toEqual(['s2', 'w1', 's1', 's3']);
|
||||
});
|
||||
|
||||
it('falls back to all-expanded when collapse cannot be stored', () => {
|
||||
const app = makeApp();
|
||||
const setItem = vi.spyOn(window.Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('quota');
|
||||
});
|
||||
app.toggleTabGroupCollapsed('group-x', true);
|
||||
setItem.mockRestore();
|
||||
expect(app.collapsedTabGroupIds.size).toBe(0);
|
||||
expect(app._tabCollapseStorageFailed).toBe(true);
|
||||
expect(document.querySelector('[data-tab-group-header="group-x"]')?.getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('survives localStorage itself being unavailable', () => {
|
||||
const app = makeApp({ tabLayout: null });
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
configurable: true,
|
||||
get: () => {
|
||||
throw new window.DOMException('denied', 'SecurityError');
|
||||
},
|
||||
});
|
||||
expect(() => app._applyTabLayout(layout)).not.toThrow();
|
||||
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(2);
|
||||
expect(app.collapsedTabGroupIds.size).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps collapse working after a malformed stored value, and repairs it', () => {
|
||||
localStorage.setItem('codeman:tab-groups-collapsed', '{"group-x":true}');
|
||||
const app = makeApp({ tabLayout: null });
|
||||
app._applyTabLayout(layout);
|
||||
expect(app._tabCollapseStorageFailed).toBe(false);
|
||||
expect(localStorage.getItem('codeman:tab-groups-collapsed')).toBe('[]');
|
||||
expect(app.toggleTabGroupCollapsed('group-x', true)).toBe(true);
|
||||
expect(localStorage.getItem('codeman:tab-groups-collapsed')).toBe('["group-x"]');
|
||||
expect(document.querySelector('[data-tab-group-header="group-x"]')?.getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
|
||||
it('lights a collapsed header with the most urgent alert it hides, on both render paths', () => {
|
||||
const app = makeApp();
|
||||
app.tabAlerts.set('s1', 'idle');
|
||||
app.collapsedTabGroupIds = new Set(['group-x']);
|
||||
app._fullRenderSessionTabs();
|
||||
const header = () => document.querySelector('[data-tab-group-header="group-x"]')!;
|
||||
const ring = () => ['tab-alert-action', 'tab-alert-idle'].filter((c) => header().classList.contains(c));
|
||||
// s1 is hidden (s2 is the kept selection): its yellow alert moves to the header.
|
||||
expect(rowIds()).toEqual(['s2', 's3']);
|
||||
expect(ring()).toEqual(['tab-alert-idle']);
|
||||
|
||||
// A red alert arriving later is patched in place, without a rebuild.
|
||||
const full = vi.spyOn(app, '_fullRenderSessionTabs');
|
||||
app.tabAlerts.set('s1', 'action');
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(full).not.toHaveBeenCalled();
|
||||
expect(ring()).toEqual(['tab-alert-action']);
|
||||
|
||||
// The kept selection draws its own ring, so its alert never reaches the header.
|
||||
app.tabAlerts.clear();
|
||||
app.tabAlerts.set('s2', 'action');
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(ring()).toEqual([]);
|
||||
expect(document.querySelector('[data-id="s2"]')!.classList.contains('tab-alert-action')).toBe(true);
|
||||
|
||||
// Expanded, every row shows its own alert and the header shows none.
|
||||
app.tabAlerts.set('s1', 'action');
|
||||
app.toggleTabGroupCollapsed('group-x', false);
|
||||
expect(ring()).toEqual([]);
|
||||
});
|
||||
|
||||
it("lets Ctrl+Shift moves swap only within the active session's own section", () => {
|
||||
const app = makeApp();
|
||||
app.saveSessionOrder = vi.fn();
|
||||
app._fullRenderSessionTabs();
|
||||
// s2's neighbours in sessionOrder (s1,s2,s3): s3 is Ungrouped, s1 shares group-x.
|
||||
app.moveActiveTabRight();
|
||||
expect(app.sessionOrder).toEqual(['s1', 's2', 's3']);
|
||||
expect(app.saveSessionOrder).not.toHaveBeenCalled();
|
||||
app.moveActiveTabLeft();
|
||||
expect(app.sessionOrder).toEqual(['s2', 's1', 's3']);
|
||||
expect(app.saveSessionOrder).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A row a collapse hides is still in its section.
|
||||
app.sessionOrder = ['s1', 's2', 's3'];
|
||||
app.collapsedTabGroupIds = new Set(['group-x']);
|
||||
app._fullRenderSessionTabs();
|
||||
expect(rowIds()).not.toContain('s1');
|
||||
app.moveActiveTabLeft();
|
||||
expect(app.sessionOrder).toEqual(['s2', 's1', 's3']);
|
||||
|
||||
// The flat rail (and the strip) keep moving across every neighbour.
|
||||
const flat = makeApp({ tabLayout: null });
|
||||
flat.saveSessionOrder = vi.fn();
|
||||
flat._fullRenderSessionTabs();
|
||||
flat.moveActiveTabRight();
|
||||
expect(flat.sessionOrder).toEqual(['s1', 's3', 's2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('layout adoption', () => {
|
||||
it('garbage-collects stale collapse ids and renders on adoption; a failed read renders flat', () => {
|
||||
localStorage.setItem('codeman:tab-groups-collapsed', '["gone","group-x"]');
|
||||
const app = makeApp({ tabLayout: null });
|
||||
app._applyTabLayout(layout);
|
||||
expect([...app.collapsedTabGroupIds]).toEqual(['group-x']);
|
||||
expect(localStorage.getItem('codeman:tab-groups-collapsed')).toBe('["group-x"]');
|
||||
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(2);
|
||||
|
||||
app._applyTabLayout(null);
|
||||
expect(app.tabLayout).toBeNull();
|
||||
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(0);
|
||||
expect(rowIds()).toEqual(['s1', 's2', 's3', 'w1']);
|
||||
});
|
||||
|
||||
it('rebuilds the strip on adoption only when what the rail draws changed', () => {
|
||||
const flat = makeApp({ tabLayout: null });
|
||||
flat._fullRenderSessionTabs();
|
||||
const flatRender = vi.spyOn(flat, '_fullRenderSessionTabs');
|
||||
// The flat rail (every owner without groups) never rebuilds for a layout read.
|
||||
flat._applyTabLayout({ version: 3, groups: [], ungrouped: [{ kind: 'session', id: 's3' }] });
|
||||
flat._applyTabLayout({ version: 4, groups: [], ungrouped: [] });
|
||||
flat._applyTabLayout(null);
|
||||
expect(flatRender).not.toHaveBeenCalled();
|
||||
|
||||
const app = makeApp({ tabLayout: null });
|
||||
app._fullRenderSessionTabs();
|
||||
const render = vi.spyOn(app, '_fullRenderSessionTabs');
|
||||
app._applyTabLayout(layout);
|
||||
expect(render).toHaveBeenCalledTimes(1);
|
||||
// A version bump that moves nothing (the broadcast after any session event).
|
||||
app._applyTabLayout({ ...layout, version: 9 });
|
||||
expect(render).toHaveBeenCalledTimes(1);
|
||||
// A rename does change the rail.
|
||||
app._applyTabLayout({ ...layout, version: 10, groups: [{ ...layout.groups[0], name: 'Ops' }] });
|
||||
expect(render).toHaveBeenCalledTimes(2);
|
||||
expect(document.querySelector('.tab-layout-group-name')?.textContent).toBe('Ops');
|
||||
// Grouped to flat (a failed read) rebuilds once.
|
||||
app._applyTabLayout(null);
|
||||
expect(render).toHaveBeenCalledTimes(3);
|
||||
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('backs off failed layout reads and stops, without rebuilding a flat rail', async () => {
|
||||
const app = makeApp({ tabLayout: null });
|
||||
app._fullRenderSessionTabs();
|
||||
app._tabLayoutCoordinator = null;
|
||||
app._apiJson = vi.fn(async () => {
|
||||
throw new Error('offline');
|
||||
});
|
||||
const render = vi.spyOn(app, '_fullRenderSessionTabs');
|
||||
const realSetTimeout = window.setTimeout;
|
||||
const delays: number[] = [];
|
||||
let queued: (() => Promise<boolean>) | null = null;
|
||||
window.setTimeout = (fn: () => Promise<boolean>, ms: number) => {
|
||||
delays.push(ms);
|
||||
queued = fn;
|
||||
return delays.length;
|
||||
};
|
||||
try {
|
||||
await app._loadTabLayout();
|
||||
// Bounded: an uncapped retry must fail the assertion below, not hang.
|
||||
for (let drained = 0; queued && drained < 10; drained++) {
|
||||
const next: () => Promise<boolean> = queued;
|
||||
queued = null;
|
||||
await next();
|
||||
}
|
||||
} finally {
|
||||
window.setTimeout = realSetTimeout;
|
||||
}
|
||||
expect(delays).toEqual([5000, 10000, 20000, 40000]);
|
||||
expect(app._apiJson).toHaveBeenCalledTimes(5);
|
||||
expect(render).not.toHaveBeenCalled();
|
||||
app._tabLayoutCoordinator.dispose();
|
||||
});
|
||||
|
||||
it('ignores an older layout than the one it already shows', () => {
|
||||
const app = makeApp();
|
||||
app.tabLayout = { ...layout, version: 9 };
|
||||
app._applyTabLayout({ ...layout, version: 8, groups: [] });
|
||||
expect(app.tabLayout.version).toBe(9);
|
||||
});
|
||||
|
||||
it('re-reads on tab:layoutChanged only for its own owner and a newer version', () => {
|
||||
const app = makeApp();
|
||||
app._loadTabLayout = vi.fn();
|
||||
app.tabLayout = { version: 8, groups: [], ungrouped: [] };
|
||||
|
||||
app._onTabLayoutChanged({ owner: '@single', version: 8 });
|
||||
expect(app._loadTabLayout).not.toHaveBeenCalled();
|
||||
app._onTabLayoutChanged({ owner: '@single', version: 9 });
|
||||
expect(app._loadTabLayout).toHaveBeenCalledTimes(1);
|
||||
|
||||
window.__codemanUser = { username: 'alice', multiUser: true };
|
||||
app._onTabLayoutChanged({ owner: 'bob', version: 20 });
|
||||
expect(app._loadTabLayout).toHaveBeenCalledTimes(1);
|
||||
app._onTabLayoutChanged({ owner: 'alice', version: 20 });
|
||||
expect(app._loadTabLayout).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('loads through GET /api/tab-layout and adopts the envelope payload', async () => {
|
||||
const app = makeApp({ tabLayout: null });
|
||||
app._tabLayoutCoordinator = null;
|
||||
app._apiJson = vi.fn(async () => ({ layout }));
|
||||
await app._loadTabLayout();
|
||||
expect(app._apiJson).toHaveBeenCalledWith('/api/tab-layout');
|
||||
expect(app.tabLayout.version).toBe(8);
|
||||
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(2);
|
||||
app._tabLayoutCoordinator.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('lineage in the grouped rail', () => {
|
||||
function rect(top: number) {
|
||||
return () => ({ left: 32, top, right: 292, bottom: top + 36, width: 260, height: 36, x: 32, y: top }) as DOMRect;
|
||||
}
|
||||
|
||||
it('anchors a collapse-hidden endpoint to its group header', () => {
|
||||
const app = makeApp();
|
||||
app.activeSessionId = 's3';
|
||||
app.sessions.get('s3').status = 'working';
|
||||
app.sessions.get('s3').parentSessionId = 's1';
|
||||
app.collapsedTabGroupIds = new Set(['group-x']);
|
||||
app._lineageLinesEnabled = () => true;
|
||||
app._fullRenderSessionTabs();
|
||||
expect(rowIds()).toEqual(['s3']);
|
||||
|
||||
document.getElementById('sessionTabs')!.getBoundingClientRect = () =>
|
||||
({ left: 0, top: 0, right: 320, bottom: 320, width: 320, height: 320 }) as DOMRect;
|
||||
document.querySelector<HTMLElement>('[data-tab-group-header="group-x"]')!.getBoundingClientRect = rect(20);
|
||||
document.querySelector<HTMLElement>('[data-id="s3"]')!.getBoundingClientRect = rect(180);
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
|
||||
app._appendLineageConnectionLines(svg, new Map());
|
||||
|
||||
const line = svg.querySelector('.lineage-line');
|
||||
expect(line?.classList.contains('lineage-line--proxied')).toBe(true);
|
||||
expect(line?.getAttribute('data-parent-tab')).toBe('s1');
|
||||
expect(svg.querySelector('.lineage-line-dot')?.classList.contains('lineage-line-dot--proxied')).toBe(true);
|
||||
expect(app._lineageEdgeCount).toBe(1);
|
||||
});
|
||||
|
||||
it('draws nothing when both endpoints hide behind the same header', () => {
|
||||
const app = makeApp();
|
||||
app.activeSessionId = 's3';
|
||||
app.sessions.get('s1').parentSessionId = 's2';
|
||||
app.collapsedTabGroupIds = new Set(['group-x']);
|
||||
app._lineageLinesEnabled = () => true;
|
||||
app._fullRenderSessionTabs();
|
||||
document.getElementById('sessionTabs')!.getBoundingClientRect = rect(0);
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
|
||||
app._appendLineageConnectionLines(svg, new Map());
|
||||
|
||||
expect(svg.querySelectorAll('.lineage-line')).toHaveLength(0);
|
||||
expect(app._lineageEdgeCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('grouped rail tree semantics', () => {
|
||||
const tabs = () => document.getElementById('sessionTabs')!;
|
||||
const press = (key: string, init: Record<string, unknown> = {}) =>
|
||||
(document.activeElement as HTMLElement).dispatchEvent(
|
||||
new window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init })
|
||||
);
|
||||
const focused = () => {
|
||||
const el = document.activeElement as HTMLElement;
|
||||
return el.dataset.tabGroupHeader ? `group:${el.dataset.tabGroupHeader}` : el.dataset.webviewId || el.dataset.id;
|
||||
};
|
||||
const row = (id: string) => document.querySelector<HTMLElement>(`[data-id="${id}"], [data-webview-id="${id}"]`)!;
|
||||
|
||||
function makeTreeApp(options: { tabLayout?: unknown } = {}) {
|
||||
const app = makeApp(options);
|
||||
// The container as index.html ships it.
|
||||
tabs().setAttribute('role', 'tablist');
|
||||
tabs().setAttribute('aria-label', 'Session tabs');
|
||||
app.selectSession = vi.fn();
|
||||
app.openWebview = vi.fn();
|
||||
app.openTabRailActionMenu = vi.fn();
|
||||
app.showWebviewModal = vi.fn();
|
||||
return app;
|
||||
}
|
||||
|
||||
it('is a tree only while grouped, and the flat list returns byte-identical as a tablist', () => {
|
||||
const app = makeTreeApp({ tabLayout: null });
|
||||
app._fullRenderSessionTabs();
|
||||
const flat = tabs().innerHTML;
|
||||
expect(tabs().querySelectorAll('[role="tree"], [role="treeitem"], [role="group"]')).toHaveLength(0);
|
||||
expect(tabs().querySelectorAll('.session-tab[role="tab"]')).toHaveLength(4);
|
||||
|
||||
app._applyTabLayout(layout);
|
||||
expect(tabs().getAttribute('role')).toBe('tree');
|
||||
expect(tabs().getAttribute('aria-label')).toBe('Sessions');
|
||||
expect(tabs().querySelectorAll('[role="tab"]')).toHaveLength(0);
|
||||
expect(tabs().querySelectorAll('.session-tab[role="treeitem"]')).toHaveLength(4);
|
||||
|
||||
app._applyTabLayout(null);
|
||||
expect(tabs().getAttribute('role')).toBe('tablist');
|
||||
expect(tabs().getAttribute('aria-label')).toBe('Session tabs');
|
||||
expect(tabs().innerHTML).toBe(flat);
|
||||
|
||||
// The horizontal strip never becomes a tree, groups or not.
|
||||
document.documentElement.setAttribute('data-tab-orientation', 'horizontal');
|
||||
app._applyTabLayout(layout);
|
||||
expect(tabs().getAttribute('role')).toBe('tablist');
|
||||
expect(tabs().querySelectorAll('[role="treeitem"]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('nests group rows under their owning header with levels and positions', () => {
|
||||
const app = makeTreeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
const header = document.querySelector<HTMLElement>('[data-tab-group-header="group-x"]')!;
|
||||
const group = document.getElementById(header.getAttribute('aria-owns')!)!;
|
||||
expect(group.getAttribute('role')).toBe('group');
|
||||
expect(
|
||||
[...group.querySelectorAll<HTMLElement>('[role="treeitem"]')].map((el) => el.dataset.webviewId || el.dataset.id)
|
||||
).toEqual(['s2', 'w1', 's1']);
|
||||
const aria = (el: HTMLElement) => ['aria-level', 'aria-posinset', 'aria-setsize'].map((a) => el.getAttribute(a));
|
||||
// Level 1: the group header and the ungrouped row.
|
||||
expect(aria(header)).toEqual(['1', '1', '2']);
|
||||
expect(aria(row('s3'))).toEqual(['1', '2', '2']);
|
||||
expect(aria(row('s2'))).toEqual(['2', '1', '3']);
|
||||
expect(aria(row('w1'))).toEqual(['2', '2', '3']);
|
||||
expect(aria(row('s1'))).toEqual(['2', '3', '3']);
|
||||
// aria-selected follows the active row, exactly once.
|
||||
expect([...tabs().querySelectorAll('[aria-selected="true"]')].map((el) => (el as HTMLElement).dataset.id)).toEqual([
|
||||
's2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('has exactly one tab stop, on the selected row, and no tabbable control inside rows', () => {
|
||||
const app = makeTreeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
const stops = [...tabs().querySelectorAll<HTMLElement>('[tabindex="0"]')];
|
||||
expect(stops).toEqual([row('s2')]);
|
||||
const controls = [...tabs().querySelectorAll<HTMLElement>('.session-tab button, .session-tab [tabindex]')];
|
||||
expect(controls.length).toBeGreaterThan(0);
|
||||
expect(controls.every((el) => el.tabIndex === -1)).toBe(true);
|
||||
|
||||
// Collapse keeps a single stop (the selection stays visible as a level-1 item).
|
||||
app.toggleTabGroupCollapsed('group-x', true);
|
||||
expect(tabs().querySelectorAll('[tabindex="0"]')).toHaveLength(1);
|
||||
expect(row('s2').getAttribute('aria-level')).toBe('1');
|
||||
expect(document.querySelector('[data-tab-group-header="group-x"]')!.hasAttribute('aria-owns')).toBe(false);
|
||||
});
|
||||
|
||||
it('walks Up/Down/Home/End, collapses and enters groups with Left/Right, and activates with Enter/Space', () => {
|
||||
const app = makeTreeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
row('s2').focus();
|
||||
press('ArrowUp');
|
||||
expect(focused()).toBe('group:group-x');
|
||||
press('ArrowUp');
|
||||
expect(focused()).toBe('s3');
|
||||
press('Home');
|
||||
expect(focused()).toBe('group:group-x');
|
||||
press('End');
|
||||
expect(focused()).toBe('s3');
|
||||
// An ungrouped row has no parent to climb to.
|
||||
expect(press('ArrowLeft')).toBe(true);
|
||||
expect(focused()).toBe('s3');
|
||||
|
||||
row('w1').focus();
|
||||
press('Enter');
|
||||
expect(app.openWebview).toHaveBeenCalledWith('w1');
|
||||
row('s1').focus();
|
||||
press(' ');
|
||||
expect(app.selectSession).toHaveBeenCalledWith('s1', { forceReload: true });
|
||||
expect(app.selectSession).toHaveBeenCalledTimes(1);
|
||||
|
||||
press('ArrowLeft');
|
||||
expect(focused()).toBe('group:group-x');
|
||||
press('ArrowLeft');
|
||||
expect(app.collapsedTabGroupIds.has('group-x')).toBe(true);
|
||||
// The header was re-rendered; focus and the tab stop moved to the new node.
|
||||
expect(focused()).toBe('group:group-x');
|
||||
expect(document.querySelector('[data-tab-group-header="group-x"]')!.getAttribute('tabindex')).toBe('0');
|
||||
press('ArrowRight');
|
||||
expect(app.collapsedTabGroupIds.has('group-x')).toBe(false);
|
||||
expect(focused()).toBe('group:group-x');
|
||||
press('ArrowRight');
|
||||
expect(focused()).toBe('s2');
|
||||
press('Home');
|
||||
press('Enter');
|
||||
expect(app.collapsedTabGroupIds.has('group-x')).toBe(true);
|
||||
// The header was rebuilt by the toggle; focus followed it, never to <body>.
|
||||
expect(focused()).toBe('group:group-x');
|
||||
expect(tabs().querySelectorAll('[tabindex="0"]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('leaves keys pressed on a control inside a row to that control', () => {
|
||||
const app = makeTreeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
// The overflow button gets focus from a click, or back from its action menu.
|
||||
const more = row('s2').querySelector<HTMLElement>('.tab-more')!;
|
||||
more.focus();
|
||||
expect(press('Enter')).toBe(true);
|
||||
expect(press(' ')).toBe(true);
|
||||
expect(press('ArrowDown')).toBe(true);
|
||||
expect(press('ArrowLeft')).toBe(true);
|
||||
expect(app.selectSession).not.toHaveBeenCalled();
|
||||
expect(app.collapsedTabGroupIds.size).toBe(0);
|
||||
expect(document.activeElement).toBe(more);
|
||||
});
|
||||
|
||||
it('treats a group with no open rows as a leaf that Left/Right cannot open or close', () => {
|
||||
const app = makeTreeApp({
|
||||
tabLayout: { ...layout, groups: [...layout.groups, { id: 'empty', name: 'Empty', refs: [] }] },
|
||||
});
|
||||
app._fullRenderSessionTabs();
|
||||
const header = document.querySelector<HTMLElement>('[data-tab-group-header="empty"]')!;
|
||||
expect(header.hasAttribute('aria-expanded')).toBe(false);
|
||||
expect(header.hasAttribute('aria-owns')).toBe(false);
|
||||
header.focus();
|
||||
press('ArrowRight');
|
||||
press('ArrowLeft');
|
||||
expect(app.collapsedTabGroupIds.size).toBe(0);
|
||||
expect(focused()).toBe('group:empty');
|
||||
});
|
||||
|
||||
it('keeps tree positions in painted order when the sorted rail re-sorts rows in place', () => {
|
||||
const app = makeTreeApp();
|
||||
document.documentElement.dataset.tabRailSort = 'activity';
|
||||
let order = new Map([
|
||||
['s2', 0],
|
||||
['s1', 1],
|
||||
['s3', 2],
|
||||
]);
|
||||
app._tabRailSortOrder = () => order;
|
||||
app._fullRenderSessionTabs();
|
||||
row('w1').style.order = '9999'; // styles.css pins web tabs last; JSDOM loads no stylesheet
|
||||
app._applyTabTreePositions(tabs());
|
||||
const pos = (id: string) => row(id).getAttribute('aria-posinset');
|
||||
expect([pos('s2'), pos('s1'), pos('w1')]).toEqual(['1', '2', '3']);
|
||||
|
||||
// s1 goes working: the incremental pass moves its card up with `order` only.
|
||||
const full = vi.spyOn(app, '_fullRenderSessionTabs');
|
||||
order = new Map([
|
||||
['s1', 0],
|
||||
['s2', 1],
|
||||
['s3', 2],
|
||||
]);
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(full).not.toHaveBeenCalled();
|
||||
expect(row('s1').style.order).toBe('0');
|
||||
expect([pos('s1'), pos('s2'), pos('w1')]).toEqual(['1', '2', '3']);
|
||||
expect(row('s1').getAttribute('aria-setsize')).toBe('3');
|
||||
});
|
||||
|
||||
it('exempts tree items from the touch keyboard dismissal, like every other control', () => {
|
||||
// terminal-ui.js publishes the selector on window.CodemanTerminalInput; its
|
||||
// top level only needs a window and a CodemanApp prototype to assign to.
|
||||
const context = vm.createContext({ window: {}, CodemanApp: function CodemanApp() {} });
|
||||
vm.runInContext(readFileSync(join(PUBLIC, 'terminal-ui.js'), 'utf8'), context);
|
||||
const selector = (context.window as any).CodemanTerminalInput.MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR as string;
|
||||
|
||||
const app = makeTreeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
const header = document.querySelector<HTMLElement>('[data-tab-group-header="group-x"]')!;
|
||||
// The roving stop sits on s2: the header and the other rows are tabindex=-1.
|
||||
expect(header.tabIndex).toBe(-1);
|
||||
expect(row('s1').tabIndex).toBe(-1);
|
||||
for (const target of [header, header.querySelector('.tab-layout-group-name')!, row('s1'), row('w1')]) {
|
||||
expect(target.closest(selector), (target as HTMLElement).className).not.toBeNull();
|
||||
}
|
||||
// Inert rail chrome still dismisses.
|
||||
expect(document.querySelector('.tab-layout-ungrouped-header')!.closest(selector)).toBeNull();
|
||||
});
|
||||
|
||||
it('opens row actions from the keyboard, since its controls left the tab order', () => {
|
||||
const app = makeTreeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
row('s2').focus();
|
||||
press('F10', { shiftKey: true });
|
||||
expect(app.openTabRailActionMenu).toHaveBeenCalledWith(expect.objectContaining({ currentTarget: row('s2') }), 's2');
|
||||
row('w1').focus();
|
||||
press('ContextMenu');
|
||||
expect(app.showWebviewModal).toHaveBeenCalledWith('w1');
|
||||
press('F10');
|
||||
expect(app.showWebviewModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('restores focus by identity across a background rebuild and follows pointer focus', () => {
|
||||
const app = makeTreeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
row('w1').focus();
|
||||
app._fullRenderSessionTabs();
|
||||
expect(focused()).toBe('w1');
|
||||
expect([...tabs().querySelectorAll('[tabindex="0"]')]).toEqual([row('w1')]);
|
||||
|
||||
// Focus arriving by pointer (or any other route) takes the tab stop with it.
|
||||
row('s3').focus();
|
||||
expect([...tabs().querySelectorAll('[tabindex="0"]')]).toEqual([row('s3')]);
|
||||
|
||||
// A rebuild never pulls focus into the rail when it was elsewhere.
|
||||
(document.activeElement as HTMLElement).blur();
|
||||
app._fullRenderSessionTabs();
|
||||
expect(document.activeElement).toBe(document.body);
|
||||
});
|
||||
|
||||
it('keeps aria-selected in step when the selection changes without a rebuild', () => {
|
||||
const app = makeTreeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
const full = vi.spyOn(app, '_fullRenderSessionTabs');
|
||||
app.activeSessionId = 's1';
|
||||
app._updateActiveTabImmediate('s1');
|
||||
expect(full).not.toHaveBeenCalled();
|
||||
expect([...tabs().querySelectorAll('[aria-selected="true"]')].map((el) => (el as HTMLElement).dataset.id)).toEqual([
|
||||
's1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('walks a sorted rail in painted order: per group in the tree, across the list when flat', () => {
|
||||
const app = makeTreeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
document.documentElement.dataset.tabRailSort = 'activity';
|
||||
row('s1').style.order = '0';
|
||||
row('s2').style.order = '1';
|
||||
row('w1').style.order = '9999';
|
||||
row('s3').style.order = '2';
|
||||
document.querySelector<HTMLElement>('[data-tab-group-header="group-x"]')!.focus();
|
||||
const walk = () =>
|
||||
Array.from({ length: 4 }, () => {
|
||||
press('ArrowDown');
|
||||
return focused();
|
||||
});
|
||||
expect(walk()).toEqual(['s1', 's2', 'w1', 's3']);
|
||||
|
||||
const flat = makeTreeApp({ tabLayout: null });
|
||||
flat._fullRenderSessionTabs();
|
||||
document.documentElement.dataset.tabRailSort = 'activity';
|
||||
row('s1').style.order = '2';
|
||||
row('s2').style.order = '0';
|
||||
row('s3').style.order = '1';
|
||||
row('w1').style.order = '9999'; // styles.css pins web tabs last; JSDOM loads no stylesheet
|
||||
row('s2').focus();
|
||||
expect(
|
||||
Array.from({ length: 3 }, () => {
|
||||
press('ArrowDown');
|
||||
return focused();
|
||||
})
|
||||
).toEqual(['s3', 's1', 'w1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flat list keyboard activation', () => {
|
||||
it('opens a web tab with Enter/Space instead of selecting an undefined session', () => {
|
||||
const app = makeApp({ tabLayout: null });
|
||||
app.selectSession = vi.fn();
|
||||
app.openWebview = vi.fn();
|
||||
app._fullRenderSessionTabs();
|
||||
const web = document.querySelector<HTMLElement>('[data-webview-id="w1"]')!;
|
||||
web.focus();
|
||||
web.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
|
||||
web.dispatchEvent(new window.KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }));
|
||||
expect(app.openWebview).toHaveBeenCalledTimes(2);
|
||||
expect(app.selectSession).not.toHaveBeenCalled();
|
||||
|
||||
const s2 = document.querySelector<HTMLElement>('[data-id="s2"]')!;
|
||||
s2.focus();
|
||||
s2.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
|
||||
expect(app.selectSession).toHaveBeenCalledWith('s2', { forceReload: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,353 @@
|
||||
// @vitest-environment node
|
||||
import { createServer, type IncomingMessage, type Server } from 'node:http';
|
||||
import { mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
buildWebhookRequest,
|
||||
DEFAULT_WEBHOOK_CONFIG,
|
||||
maskWebhookUrl,
|
||||
readWebhookConfig,
|
||||
sendWebhook,
|
||||
shouldSendWebhook,
|
||||
webhookConfigPath,
|
||||
WebhookNotifier,
|
||||
webhookUrlProblem,
|
||||
writeWebhookConfig,
|
||||
type WebhookFetch,
|
||||
type WebhookMessage,
|
||||
} from '../src/webhook-notify.js';
|
||||
import type { WebhookConfig } from '../src/types/push.js';
|
||||
import { webviewFetch, WebviewEgressBlockedError } from '../src/web/webview-egress.js';
|
||||
|
||||
const MSG: WebhookMessage = {
|
||||
event: 'hook:permission_prompt',
|
||||
title: 'Permission Required',
|
||||
body: '[w1-app] Tool: Bash',
|
||||
urgency: 'critical',
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1-app',
|
||||
host: 'codeman:box',
|
||||
};
|
||||
const CFG: WebhookConfig = {
|
||||
enabled: true,
|
||||
kind: 'generic',
|
||||
url: 'https://hooks.example.com/T0/B0/secret',
|
||||
scope: 'attention',
|
||||
};
|
||||
// A 204 (like a 3xx with no body) must be built without one.
|
||||
const ok = (status = 200) => new Response(status === 204 ? null : '', { status });
|
||||
|
||||
describe('webhookUrlProblem', () => {
|
||||
it.each([
|
||||
'https://ntfy.sh/mytopic',
|
||||
'https://hooks.slack.com/services/T/B/x',
|
||||
'http://localhost:8080/t',
|
||||
'http://192.168.1.5/hook',
|
||||
])('accepts %s (loopback and LAN are the point of a local ntfy)', (url) => expect(webhookUrlProblem(url)).toBeNull());
|
||||
|
||||
it.each([
|
||||
['ftp://example.com/x', /http and https/],
|
||||
['file:///etc/passwd', /http and https/],
|
||||
['https://user:pw@example.com/x', /credentials/],
|
||||
['not a url', /valid URL/],
|
||||
['http://169.254.169.254/latest/meta-data', /link-local|metadata/],
|
||||
['http://metadata.google.internal/computeMetadata/v1/', /metadata/],
|
||||
['http://[fd00:ec2::254]/', /metadata|link-local/],
|
||||
[`https://example.com/${'a'.repeat(2100)}`, /too long/],
|
||||
])('rejects %s', (url, why) => expect(webhookUrlProblem(url)).toMatch(why));
|
||||
});
|
||||
|
||||
describe('maskWebhookUrl', () => {
|
||||
it('keeps scheme and host and drops the secret path and query', () => {
|
||||
const masked = maskWebhookUrl('https://hooks.slack.com/services/T0/B0/XXXXSECRET?token=abc');
|
||||
expect(masked).toBe('https://hooks.slack.com/•••');
|
||||
expect(masked).not.toMatch(/SECRET|token|T0/);
|
||||
});
|
||||
it('is empty for nothing or garbage', () => {
|
||||
expect(maskWebhookUrl('')).toBe('');
|
||||
expect(maskWebhookUrl('nope')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldSendWebhook', () => {
|
||||
it('needs enabled and a url', () => {
|
||||
expect(shouldSendWebhook({ ...CFG, enabled: false }, 'critical')).toBe(false);
|
||||
expect(shouldSendWebhook({ ...CFG, url: '' }, 'critical')).toBe(false);
|
||||
expect(shouldSendWebhook(CFG, 'critical')).toBe(true);
|
||||
});
|
||||
it('scope attention skips "response complete" (info); scope all sends it', () => {
|
||||
expect(shouldSendWebhook(CFG, 'warning')).toBe(true);
|
||||
expect(shouldSendWebhook(CFG, 'info')).toBe(false);
|
||||
expect(shouldSendWebhook({ ...CFG, scope: 'all' }, 'info')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildWebhookRequest', () => {
|
||||
it('ntfy: plain-text body, priority and tag by urgency, host-prefixed title', () => {
|
||||
const r = buildWebhookRequest('ntfy', MSG);
|
||||
expect(r.body).toBe('[w1-app] Tool: Bash');
|
||||
expect(r.headers.Title).toBe('codeman:box: Permission Required');
|
||||
expect(r.headers.Priority).toBe('5');
|
||||
expect(buildWebhookRequest('ntfy', { ...MSG, urgency: 'info' }).headers.Priority).toBe('3');
|
||||
expect(buildWebhookRequest('ntfy', { ...MSG, urgency: 'warning' }).headers.Priority).toBe('4');
|
||||
});
|
||||
|
||||
it('ntfy: a non-ASCII or multi-line title can never break the header (RFC 2047 encoded)', () => {
|
||||
const r = buildWebhookRequest('ntfy', { ...MSG, title: 'Prüfung\r\nX-Injected: 1', host: undefined });
|
||||
expect(r.headers.Title).toMatch(/^=\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=$/);
|
||||
expect(Buffer.from(r.headers.Title.slice(10, -2), 'base64').toString('utf8')).toBe('Prüfung X-Injected: 1');
|
||||
expect(Object.keys(r.headers)).not.toContain('X-Injected');
|
||||
});
|
||||
|
||||
it('slack: control characters are escaped so agent text cannot ping a channel', () => {
|
||||
const r = JSON.parse(
|
||||
buildWebhookRequest('slack', { ...MSG, body: '<!channel> <@U123> <https://evil|click> & more' }).body
|
||||
);
|
||||
expect(r.text).not.toMatch(/<[!@h]/);
|
||||
expect(r.text).toContain('<!channel>');
|
||||
expect(r.text).toContain('& more');
|
||||
});
|
||||
|
||||
it('discord: mentions are disabled and the content is length-capped', () => {
|
||||
const r = JSON.parse(buildWebhookRequest('discord', { ...MSG, body: '@everyone ' + 'x'.repeat(5000) }).body);
|
||||
expect(r.allowed_mentions).toEqual({ parse: [] });
|
||||
expect(r.content.length).toBeLessThanOrEqual(1900);
|
||||
});
|
||||
|
||||
it('generic: structured JSON with the session and a timestamp', () => {
|
||||
const r = JSON.parse(buildWebhookRequest('generic', MSG, new Date('2026-10-02T12:00:00Z')).body);
|
||||
expect(r).toEqual({
|
||||
event: 'hook:permission_prompt',
|
||||
title: 'Permission Required',
|
||||
body: '[w1-app] Tool: Bash',
|
||||
urgency: 'critical',
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1-app',
|
||||
host: 'codeman:box',
|
||||
at: '2026-10-02T12:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('truncates a long body for every kind', () => {
|
||||
const long = 'y'.repeat(2000);
|
||||
expect(buildWebhookRequest('ntfy', { ...MSG, body: long }).body.length).toBeLessThanOrEqual(500);
|
||||
expect(JSON.parse(buildWebhookRequest('generic', { ...MSG, body: long }).body).body.length).toBeLessThanOrEqual(
|
||||
500
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('store', () => {
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'webhook-'));
|
||||
});
|
||||
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
it('returns the defaults for a missing or corrupt file', async () => {
|
||||
expect(await readWebhookConfig(dir)).toEqual(DEFAULT_WEBHOOK_CONFIG);
|
||||
writeFileSync(webhookConfigPath(dir), '{ nope');
|
||||
expect(await readWebhookConfig(dir)).toEqual(DEFAULT_WEBHOOK_CONFIG);
|
||||
});
|
||||
|
||||
it('round-trips and writes the file readable by its owner only', async () => {
|
||||
await writeWebhookConfig(dir, CFG);
|
||||
expect(await readWebhookConfig(dir)).toEqual(CFG);
|
||||
expect(statSync(webhookConfigPath(dir)).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('tightens an existing world-readable file instead of keeping its mode', async () => {
|
||||
writeFileSync(webhookConfigPath(dir), '{}', { mode: 0o644 });
|
||||
await writeWebhookConfig(dir, CFG);
|
||||
expect(statSync(webhookConfigPath(dir)).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('coerces unknown kinds and scopes back to the defaults', async () => {
|
||||
writeFileSync(
|
||||
webhookConfigPath(dir),
|
||||
JSON.stringify({ enabled: true, kind: 'telegram', scope: 'nope', url: 'https://x.test/h' })
|
||||
);
|
||||
const cfg = await readWebhookConfig(dir);
|
||||
expect(cfg).toEqual({ enabled: true, kind: 'ntfy', scope: 'attention', url: 'https://x.test/h' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendWebhook', () => {
|
||||
it('posts the built request with redirects off and a timeout signal', async () => {
|
||||
const fetchImpl = vi.fn<WebhookFetch>(async () => ok(204));
|
||||
const r = await sendWebhook(CFG, MSG, fetchImpl);
|
||||
expect(r).toMatchObject({ ok: true, status: 204 });
|
||||
const [target, init] = fetchImpl.mock.calls[0];
|
||||
expect(target.href).toBe(CFG.url);
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.redirect).toBe('manual');
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it('reports an HTTP failure by status, a redirect as such, and never echoes the URL', async () => {
|
||||
const bad = await sendWebhook(CFG, MSG, async () => ok(404));
|
||||
expect(bad).toMatchObject({ ok: false, status: 404, error: 'HTTP 404' });
|
||||
const redirect = await sendWebhook(CFG, MSG, async () => ok(302));
|
||||
expect(redirect.ok).toBe(false);
|
||||
expect(redirect.error).toMatch(/redirects/);
|
||||
for (const r of [bad, redirect]) expect(JSON.stringify(r)).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('turns network errors into short messages that do not contain the URL', async () => {
|
||||
const cases: [unknown, RegExp][] = [
|
||||
[Object.assign(new Error('x'), { name: 'TimeoutError' }), /Timed out/],
|
||||
[Object.assign(new TypeError('fetch failed'), { cause: { code: 'ENOTFOUND' } }), /Host not found/],
|
||||
[Object.assign(new TypeError('fetch failed'), { cause: { code: 'ECONNREFUSED' } }), /refused/],
|
||||
[new TypeError('fetch failed https://hooks.example.com/T0/B0/secret'), /Network error/],
|
||||
];
|
||||
for (const [err, re] of cases) {
|
||||
const r = await sendWebhook(CFG, MSG, async () => {
|
||||
throw err;
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toMatch(re);
|
||||
expect(JSON.stringify(r)).not.toContain('secret');
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a blocked URL without fetching', async () => {
|
||||
const fetchImpl = vi.fn<WebhookFetch>(async () => ok());
|
||||
const r = await sendWebhook({ ...CFG, url: 'http://169.254.169.254/latest' }, MSG, fetchImpl);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('recognises an egress refusal by its code anywhere in the cause chain, not by message text', async () => {
|
||||
// A DNS name resolving into a blocked range is refused by the connect-time lookup, and undici
|
||||
// hands that back wrapped (`fetch failed` -> connect error -> the refusal), so it can sit deep.
|
||||
const blocked = new WebviewEgressBlockedError('cloud metadata');
|
||||
const deep = new TypeError('fetch failed', { cause: new Error('connect failed', { cause: blocked }) });
|
||||
for (const err of [blocked, deep]) {
|
||||
const r = await sendWebhook(CFG, MSG, async () => {
|
||||
throw err;
|
||||
});
|
||||
expect(r.error).toBe('Refused: target is a link-local or cloud-metadata address');
|
||||
}
|
||||
// Words alone (an upstream error that happens to mention them) are not a refusal.
|
||||
const r = await sendWebhook(CFG, MSG, async () => {
|
||||
throw new TypeError('fetch failed', { cause: new Error('link-local EGRESS hiccup') });
|
||||
});
|
||||
expect(r.error).toBe('Network error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delivery through the real egress-guarded fetch', () => {
|
||||
let server: Server;
|
||||
let received: { headers: IncomingMessage['headers']; body: string } | null;
|
||||
let port: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
received = null;
|
||||
server = createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', (c) => (body += c));
|
||||
req.on('end', () => {
|
||||
received = { headers: req.headers, body };
|
||||
res.statusCode = req.url === '/redirect' ? 302 : 200;
|
||||
if (req.url === '/redirect') res.setHeader('Location', 'http://169.254.169.254/');
|
||||
res.end('ok');
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
port = (server.address() as AddressInfo).port;
|
||||
});
|
||||
afterEach(() => new Promise<void>((resolve) => server.close(() => resolve())));
|
||||
|
||||
it('delivers to a local server (loopback is allowed) with the ntfy headers', async () => {
|
||||
const r = await sendWebhook({ kind: 'ntfy', url: `http://127.0.0.1:${port}/topic` }, MSG, webviewFetch);
|
||||
expect(r).toMatchObject({ ok: true, status: 200 });
|
||||
expect(received?.body).toBe('[w1-app] Tool: Bash');
|
||||
expect(received?.headers.priority).toBe('5');
|
||||
});
|
||||
|
||||
it('does not follow a redirect to a metadata address', async () => {
|
||||
const r = await sendWebhook({ kind: 'generic', url: `http://127.0.0.1:${port}/redirect` }, MSG, webviewFetch);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toMatch(/redirects/);
|
||||
});
|
||||
|
||||
it('refuses a metadata address at the fetch layer too', async () => {
|
||||
await expect(webviewFetch(new URL('http://169.254.169.254/'))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WebhookNotifier', () => {
|
||||
const make = (cfg: Partial<WebhookConfig> = {}, fetchImpl: WebhookFetch = async () => ok(), now?: () => number) => {
|
||||
const sent = vi.fn(fetchImpl);
|
||||
const notifier = new WebhookNotifier(async () => ({ ...CFG, ...cfg }), sent, now);
|
||||
return { notifier, sent };
|
||||
};
|
||||
|
||||
it('sends an event that needs attention and records the result', async () => {
|
||||
const { notifier, sent } = make();
|
||||
await notifier.notify(MSG);
|
||||
expect(sent).toHaveBeenCalledTimes(1);
|
||||
expect(notifier.lastResult).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it('sends nothing when disabled, without a url, or for info under scope attention', async () => {
|
||||
for (const cfg of [{ enabled: false }, { url: '' }]) {
|
||||
const { notifier, sent } = make(cfg);
|
||||
await notifier.notify(MSG);
|
||||
expect(sent).not.toHaveBeenCalled();
|
||||
}
|
||||
const { notifier, sent } = make();
|
||||
await notifier.notify({ ...MSG, urgency: 'info' });
|
||||
expect(sent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends the same event for the same session once per window, then again', async () => {
|
||||
let t = 1_000_000;
|
||||
const { notifier, sent } = make(
|
||||
{},
|
||||
async () => ok(),
|
||||
() => t
|
||||
);
|
||||
await notifier.notify(MSG);
|
||||
t += 1000;
|
||||
await notifier.notify(MSG);
|
||||
expect(sent).toHaveBeenCalledTimes(1);
|
||||
await notifier.notify({ ...MSG, sessionId: 's2' });
|
||||
expect(sent).toHaveBeenCalledTimes(2);
|
||||
t += 5000;
|
||||
await notifier.notify(MSG);
|
||||
expect(sent).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('caps what is in flight so a hung endpoint cannot pile up requests', async () => {
|
||||
let release: () => void = () => undefined;
|
||||
const gate = new Promise<void>((r) => (release = r));
|
||||
const { notifier, sent } = make({}, async () => (await gate, ok()));
|
||||
const pending = Array.from({ length: 12 }, (_, i) => notifier.notify({ ...MSG, sessionId: `s${i}` }));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(sent).toHaveBeenCalledTimes(5);
|
||||
release();
|
||||
await Promise.all(pending);
|
||||
});
|
||||
|
||||
it('a test send ignores enabled and scope, bypasses dedupe, and records the result', async () => {
|
||||
const { notifier, sent } = make({ enabled: false });
|
||||
const r1 = await notifier.sendTest(CFG, 'codeman:box');
|
||||
const r2 = await notifier.sendTest(CFG);
|
||||
expect(r1.ok && r2.ok).toBe(true);
|
||||
expect(sent).toHaveBeenCalledTimes(2);
|
||||
expect(notifier.lastResult).toBe(r2);
|
||||
expect(JSON.parse(sent.mock.calls[0][1].body as string).host).toBe('codeman:box');
|
||||
});
|
||||
|
||||
it('never throws on a failing endpoint', async () => {
|
||||
const { notifier } = make({}, async () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
await expect(notifier.notify(MSG)).resolves.toBeUndefined();
|
||||
expect(notifier.lastResult).toMatchObject({ ok: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* @fileoverview The glue between `WebServer.sendPushNotifications()` and the webhook channel.
|
||||
*
|
||||
* The notifier and the routes are tested on their own (webhook-notify.test.ts,
|
||||
* routes/webhook-routes.test.ts); this file pins the one line that makes the feature exist: the
|
||||
* webhook fires even when there is NO Web Push subscription, which is exactly the headless server
|
||||
* it was built for. Moving the call below the "no subscriptions" early return passes every other
|
||||
* test and silently kills the feature, so it is asserted here end to end: a real WebServer (never
|
||||
* started), an empty push store, `webhook.json` in the instance data dir, and a local HTTP receiver
|
||||
* reached through the real egress-guarded fetch.
|
||||
*
|
||||
* Port: N/A (no server start; the receiver binds an ephemeral 127.0.0.1 port).
|
||||
*/
|
||||
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { sendNotification } = vi.hoisted(() => ({ sendNotification: vi.fn(async () => undefined) }));
|
||||
vi.mock('web-push', () => ({
|
||||
default: { sendNotification, setVapidDetails: vi.fn(), generateVAPIDKeys: vi.fn() },
|
||||
}));
|
||||
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { getDataDir } from '../src/config/instance.js';
|
||||
import { writeWebhookConfig, type WebhookMessage, type WebhookNotifier } from '../src/webhook-notify.js';
|
||||
import type { WebhookConfig } from '../src/types/push.js';
|
||||
|
||||
interface Received {
|
||||
url?: string;
|
||||
title?: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
type PushSender = { sendPushNotifications: (e: string, d: Record<string, unknown>) => Promise<void> };
|
||||
|
||||
let receiver: Server;
|
||||
let receiverUrl: string;
|
||||
const got: Received[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
receiver = createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', (c) => (body += c));
|
||||
req.on('end', () => {
|
||||
// Recorded before the response goes out, so a resolved notify() means it is already here.
|
||||
got.push({ url: req.url, title: req.headers.title as string | undefined, body });
|
||||
res.end('ok');
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => receiver.listen(0, '127.0.0.1', resolve));
|
||||
receiverUrl = `http://127.0.0.1:${(receiver.address() as AddressInfo).port}/codeman-topic`;
|
||||
});
|
||||
|
||||
afterAll(() => new Promise<void>((resolve) => receiver.close(() => resolve())));
|
||||
|
||||
beforeEach(() => {
|
||||
got.length = 0;
|
||||
sendNotification.mockClear();
|
||||
});
|
||||
|
||||
/**
|
||||
* A headless server: no push subscription at all. The real notifier stays in place; its `notify`
|
||||
* is only wrapped to collect the promises `sendPushNotifications` fires and forgets, so a test can
|
||||
* await delivery instead of sleeping.
|
||||
*/
|
||||
async function headlessServer(cfg: Partial<WebhookConfig>) {
|
||||
await writeWebhookConfig(getDataDir(), { enabled: true, kind: 'ntfy', scope: 'attention', url: receiverUrl, ...cfg });
|
||||
const server = new WebServer(0, false, true, '127.0.0.1', 'box');
|
||||
(server as unknown as { pushStore: { getAll: () => never[] } }).pushStore = { getAll: () => [] };
|
||||
const notifier = (server as unknown as { webhookNotifier: WebhookNotifier }).webhookNotifier;
|
||||
const pending: Promise<void>[] = [];
|
||||
const realNotify = notifier.notify.bind(notifier);
|
||||
notifier.notify = (msg: WebhookMessage) => {
|
||||
const p = realNotify(msg);
|
||||
pending.push(p);
|
||||
return p;
|
||||
};
|
||||
const send = async (event: string, data: Record<string, unknown>) => {
|
||||
await (server as unknown as PushSender).sendPushNotifications(event, data);
|
||||
await Promise.all(pending.splice(0));
|
||||
};
|
||||
return { send };
|
||||
}
|
||||
|
||||
describe('sendPushNotifications -> webhook (the headless case)', () => {
|
||||
it('delivers a permission prompt to the webhook with zero push subscriptions', async () => {
|
||||
const { send } = await headlessServer({});
|
||||
await send('hook:permission_prompt', { sessionId: 's1', sessionName: 'w1-app', tool_name: 'Bash' });
|
||||
expect(got).toHaveLength(1);
|
||||
expect(got[0]).toEqual({
|
||||
url: '/codeman-topic',
|
||||
title: 'codeman:box: Permission Required',
|
||||
body: '[w1-app] Tool: Bash',
|
||||
});
|
||||
// Web Push had nobody to send to, and the webhook did not need it.
|
||||
expect(sendNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dedupes an immediate repeat of the same event for the same session', async () => {
|
||||
const { send } = await headlessServer({});
|
||||
await send('hook:idle_prompt', { sessionId: 's1', sessionName: 'w1-app' });
|
||||
await send('hook:idle_prompt', { sessionId: 's1', sessionName: 'w1-app' });
|
||||
expect(got).toHaveLength(1);
|
||||
await send('hook:idle_prompt', { sessionId: 's2', sessionName: 'w2-app' });
|
||||
expect(got).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('skips "response complete" under scope attention and sends it under scope all', async () => {
|
||||
const attention = await headlessServer({ scope: 'attention' });
|
||||
await attention.send('hook:stop', { sessionId: 's1', sessionName: 'w1-app' });
|
||||
expect(got).toHaveLength(0);
|
||||
|
||||
const all = await headlessServer({ scope: 'all' });
|
||||
await all.send('hook:stop', { sessionId: 's1', sessionName: 'w1-app' });
|
||||
expect(got).toHaveLength(1);
|
||||
expect(got[0].title).toBe('codeman:box: Response Complete');
|
||||
});
|
||||
|
||||
it('sends nothing while disabled, and nothing for an event Web Push does not carry', async () => {
|
||||
const disabled = await headlessServer({ enabled: false });
|
||||
await disabled.send('hook:permission_prompt', { sessionId: 's1', sessionName: 'w1-app', tool_name: 'Bash' });
|
||||
const enabled = await headlessServer({});
|
||||
await enabled.send('session:created', { sessionId: 's1', sessionName: 'w1-app' });
|
||||
expect(got).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/** @fileoverview Settings → Notifications → Webhook, end to end: real server, real Chromium, a local receiver. */
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3195;
|
||||
const SECRET = 'SUPERSECRET-topic-123';
|
||||
|
||||
describe('Webhook settings in a real browser', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
let receiver: Server;
|
||||
let receiverPort: number;
|
||||
let respondWith = 200;
|
||||
const got: { url?: string; title?: string; body: string }[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
receiver = createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', (c) => (body += c));
|
||||
req.on('end', () => {
|
||||
got.push({ url: req.url, title: req.headers.title as string | undefined, body });
|
||||
res.statusCode = respondWith;
|
||||
res.end('x');
|
||||
});
|
||||
});
|
||||
await new Promise<void>((r) => receiver.listen(0, '127.0.0.1', r));
|
||||
receiverPort = (receiver.address() as AddressInfo).port;
|
||||
|
||||
server = new WebServer(PORT, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage();
|
||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
await page.evaluate(() => (window as any).app.openAppSettings());
|
||||
await page.waitForSelector('#webhookGroup', { state: 'attached' });
|
||||
await page.waitForFunction(() => document.getElementById('webhookGroup')!.style.display !== 'none');
|
||||
}, 90000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
await new Promise<void>((r) => receiver.close(() => r()));
|
||||
}, 60000);
|
||||
|
||||
const result = () => page.textContent('#webhookResult');
|
||||
|
||||
// The checkbox sits behind a styled slider, so click the switch like a user does.
|
||||
const setSwitch = async (on: boolean) => {
|
||||
if ((await page.isChecked('#webhookEnabled')) !== on) await page.click('label.switch:has(#webhookEnabled)');
|
||||
expect(await page.isChecked('#webhookEnabled')).toBe(on);
|
||||
};
|
||||
|
||||
it('shows the group, starts empty, and refuses to enable without a URL', async () => {
|
||||
expect(await page.textContent('#webhookUrlHint')).toBe('Nothing saved yet.');
|
||||
await setSwitch(true);
|
||||
await page.click('#webhookSaveBtn');
|
||||
await page.waitForFunction(() =>
|
||||
/Add a webhook URL/.test(document.getElementById('webhookResult')?.textContent ?? '')
|
||||
);
|
||||
await setSwitch(false);
|
||||
});
|
||||
|
||||
it('refuses a cloud-metadata URL with the server’s reason', async () => {
|
||||
await page.fill('#webhookUrl', 'http://169.254.169.254/latest');
|
||||
await page.click('#webhookSaveBtn');
|
||||
await page.waitForFunction(() =>
|
||||
/metadata|link-local/.test(document.getElementById('webhookResult')?.textContent ?? '')
|
||||
);
|
||||
});
|
||||
|
||||
it('saves a URL, shows only scheme and host, and empties the secret field', async () => {
|
||||
await page.selectOption('#webhookKind', 'ntfy');
|
||||
await page.fill('#webhookUrl', `http://127.0.0.1:${receiverPort}/${SECRET}`);
|
||||
await setSwitch(true);
|
||||
await page.click('#webhookSaveBtn');
|
||||
await page.waitForFunction(() => /Saved\./.test(document.getElementById('webhookResult')?.textContent ?? ''));
|
||||
expect(await page.textContent('#webhookUrlHint')).toBe(`Saved: http://127.0.0.1:${receiverPort}/•••`);
|
||||
expect(await page.inputValue('#webhookUrl')).toBe('');
|
||||
expect(await page.content()).not.toContain(SECRET);
|
||||
// ...and GET /api/webhook never returns it either.
|
||||
const body = await page.evaluate(async () => (await fetch('/api/webhook')).text());
|
||||
expect(body).not.toContain('SUPERSECRET');
|
||||
});
|
||||
|
||||
it('sends a test message that reaches the receiver with the ntfy headers', async () => {
|
||||
got.length = 0;
|
||||
await page.click('#webhookTestBtn');
|
||||
await page.waitForFunction(() => /Test sent/.test(document.getElementById('webhookResult')?.textContent ?? ''));
|
||||
expect(got).toHaveLength(1);
|
||||
expect(got[0].url).toBe(`/${SECRET}`);
|
||||
expect(got[0].title).toMatch(/Codeman test notification/);
|
||||
expect(got[0].body).toMatch(/webhook notifications are working/);
|
||||
});
|
||||
|
||||
it('reports a failing endpoint without exposing the URL', async () => {
|
||||
respondWith = 500;
|
||||
await page.click('#webhookTestBtn');
|
||||
await page.waitForFunction(() =>
|
||||
/Delivery failed: HTTP 500/.test(document.getElementById('webhookResult')?.textContent ?? '')
|
||||
);
|
||||
expect(await result()).not.toContain(SECRET);
|
||||
respondWith = 200;
|
||||
});
|
||||
|
||||
it('keeps the saved URL when only the service changes', async () => {
|
||||
got.length = 0;
|
||||
await page.selectOption('#webhookKind', 'generic');
|
||||
await page.click('#webhookSaveBtn');
|
||||
await page.waitForFunction(() => /Saved\./.test(document.getElementById('webhookResult')?.textContent ?? ''));
|
||||
await page.click('#webhookTestBtn');
|
||||
await page.waitForFunction(() => /Test sent/.test(document.getElementById('webhookResult')?.textContent ?? ''));
|
||||
expect(JSON.parse(got[0].body)).toMatchObject({ event: 'webhook:test', urgency: 'info' });
|
||||
});
|
||||
|
||||
const savedWebhook = () => page.evaluate(async () => (await (await fetch('/api/webhook')).json()).data);
|
||||
const modalOpen = () =>
|
||||
page.evaluate(() => document.getElementById('appSettingsModal')!.classList.contains('active'));
|
||||
|
||||
it('the main Settings Save also saves a pending webhook edit', async () => {
|
||||
await page.selectOption('#webhookScope', 'all');
|
||||
await page.click('#appSettingsModal .set-foot .btn-primary');
|
||||
await page.waitForFunction(() => !document.getElementById('appSettingsModal')!.classList.contains('active'));
|
||||
expect(await savedWebhook()).toMatchObject({ scope: 'all', kind: 'generic', enabled: true, hasUrl: true });
|
||||
await page.evaluate(() => (window as any).app.openAppSettings());
|
||||
await page.waitForFunction(() => (window as any).app._webhookLoaded?.scope === 'all');
|
||||
});
|
||||
|
||||
it('a refused webhook keeps the modal open with the pasted URL, instead of a silent success', async () => {
|
||||
await page.fill('#webhookUrl', 'http://169.254.169.254/latest');
|
||||
await page.click('#appSettingsModal .set-foot .btn-primary');
|
||||
await page.waitForFunction(() =>
|
||||
/metadata|link-local/.test(document.getElementById('webhookResult')?.textContent ?? '')
|
||||
);
|
||||
expect(await modalOpen()).toBe(true);
|
||||
expect(await page.inputValue('#webhookUrl')).toBe('http://169.254.169.254/latest');
|
||||
expect((await savedWebhook()).urlMasked).toBe(`http://127.0.0.1:${receiverPort}/•••`);
|
||||
await page.fill('#webhookUrl', '');
|
||||
});
|
||||
|
||||
it('Send test saves a newly pasted URL first, so it never tests the old one', async () => {
|
||||
got.length = 0;
|
||||
await page.fill('#webhookUrl', `http://127.0.0.1:${receiverPort}/other-topic`);
|
||||
await page.click('#webhookTestBtn');
|
||||
await page.waitForFunction(() => /Test sent/.test(document.getElementById('webhookResult')?.textContent ?? ''));
|
||||
expect(got).toHaveLength(1);
|
||||
expect(got[0].url).toBe('/other-topic');
|
||||
expect(await page.inputValue('#webhookUrl')).toBe('');
|
||||
});
|
||||
|
||||
it('Remove URL deletes the saved secret and turns the channel off', async () => {
|
||||
expect(await page.isVisible('#webhookClearBtn')).toBe(true);
|
||||
page.once('dialog', (d) => void d.accept());
|
||||
await page.click('#webhookClearBtn');
|
||||
await page.waitForFunction(() => /removed/.test(document.getElementById('webhookResult')?.textContent ?? ''));
|
||||
expect(await page.textContent('#webhookUrlHint')).toBe('Nothing saved yet.');
|
||||
expect(await page.isChecked('#webhookEnabled')).toBe(false);
|
||||
expect(await page.isVisible('#webhookClearBtn')).toBe(false);
|
||||
expect(await savedWebhook()).toMatchObject({ hasUrl: false, enabled: false, urlMasked: '' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user