317 lines
11 KiB
JavaScript
317 lines
11 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
import { stringify as stringifyToml } from '@decimalturn/toml-patch';
|
|
import {
|
|
assertManagedHook,
|
|
cliPath,
|
|
createFixture,
|
|
installConfig,
|
|
legacyCommand,
|
|
managedHook,
|
|
readKimiConfig,
|
|
registryModuleUrl,
|
|
runNode,
|
|
runPlugin,
|
|
} from '../test-support/kimi-test-helpers.mjs';
|
|
|
|
const userStableConfig = [
|
|
'# stable user config',
|
|
'default_model = "kimi-code/k3"',
|
|
'',
|
|
'[[hooks]]',
|
|
'event = "SessionStart"',
|
|
'command = "existing-stable"',
|
|
'timeout = 30 # keep stable hook',
|
|
'',
|
|
].join('\n');
|
|
|
|
const userLegacyConfig = [
|
|
'# legacy user config',
|
|
'telemetry = false',
|
|
'',
|
|
'[[hooks]]',
|
|
'event = "SessionEnd"',
|
|
'command = "existing-legacy"',
|
|
'',
|
|
].join('\n');
|
|
|
|
async function assertMissing(filePath) {
|
|
await assert.rejects(readFile(filePath), { code: 'ENOENT' });
|
|
}
|
|
|
|
function assertManagedTriple(config) {
|
|
assertManagedHook(managedHook(config, 'PreToolUse'), 'PreToolUse');
|
|
assertManagedHook(managedHook(config, 'PostToolUse'), 'PostToolUse');
|
|
assertManagedHook(managedHook(config, 'PostToolUseFailure'), 'PostToolUseFailure');
|
|
assert.equal(
|
|
managedHook(config, 'PostToolUse').command,
|
|
managedHook(config, 'PostToolUseFailure').command,
|
|
);
|
|
}
|
|
|
|
test('Kimi is registered in the SDK and CLI', async () => {
|
|
const { SUPPORTED_AGENTS } = await import(registryModuleUrl);
|
|
assert.deepEqual(SUPPORTED_AGENTS.get('kimi'), {
|
|
name: 'Kimi Code',
|
|
configDir: '~/.kimi-code',
|
|
configFile: 'config.toml',
|
|
});
|
|
|
|
const fixture = await createFixture({ legacyDetected: false });
|
|
try {
|
|
const result = await runNode(['--no-warnings', cliPath, 'status'], {
|
|
HOME: fixture.homeDir,
|
|
USERPROFILE: fixture.homeDir,
|
|
KIMI_CODE_HOME: fixture.kimiHome,
|
|
}, fixture.projectDir);
|
|
assert.equal(result.code, 0, result.stderr);
|
|
assert.match(result.stdout, /Kimi Code \(kimi\)/);
|
|
} finally {
|
|
await fixture.close();
|
|
}
|
|
});
|
|
|
|
test('Kimi installs an exact managed triple in both configs and is idempotent', async () => {
|
|
const fixture = await createFixture({
|
|
stableConfig: userStableConfig,
|
|
legacyConfig: userLegacyConfig,
|
|
});
|
|
try {
|
|
const first = await fixture.install();
|
|
assert.equal(first.code, 0, first.stderr);
|
|
assert.match(first.stdout, /Kimi Code and kimi-cli/i);
|
|
const firstSources = [];
|
|
for (const [configPath, comment, userCommand] of [
|
|
[fixture.stablePath, '# stable user config', 'existing-stable'],
|
|
[fixture.legacyPath, '# legacy user config', 'existing-legacy'],
|
|
]) {
|
|
const { raw, config } = await readKimiConfig(configPath);
|
|
firstSources.push(raw);
|
|
assert.match(raw, new RegExp(comment));
|
|
assert(config.hooks.some((hook) => hook.command === userCommand));
|
|
assert.equal(config.hooks.length, 4);
|
|
assertManagedTriple(config);
|
|
}
|
|
|
|
const second = await fixture.install();
|
|
assert.equal(second.code, 0, second.stderr);
|
|
assert.equal(await readFile(fixture.stablePath, 'utf-8'), firstSources[0]);
|
|
assert.equal(await readFile(fixture.legacyPath, 'utf-8'), firstSources[1]);
|
|
await assertMissing(path.join(fixture.homeDir, '.kimi-code', 'config.toml'));
|
|
} finally {
|
|
await fixture.close();
|
|
}
|
|
});
|
|
|
|
test('fresh stable Kimi installation leaves the legacy migration source absent', async () => {
|
|
const fixture = await createFixture({ legacyDetected: false });
|
|
try {
|
|
const result = await fixture.install();
|
|
assert.equal(result.code, 0, result.stderr);
|
|
const { config } = await readKimiConfig(fixture.stablePath);
|
|
assert.equal(config.hooks.length, 3);
|
|
assertManagedTriple(config);
|
|
await assertMissing(fixture.legacyPath);
|
|
} finally {
|
|
await fixture.close();
|
|
}
|
|
});
|
|
|
|
test('empty KIMI_CODE_HOME resolves to the documented default', async () => {
|
|
const fixture = await createFixture({
|
|
explicitKimiHome: false,
|
|
legacyDetected: false,
|
|
});
|
|
try {
|
|
assert.equal((await fixture.install()).code, 0);
|
|
fixture.kimiHomeOverride = '';
|
|
const status = await runPlugin(fixture, 'status', null);
|
|
assert.equal(status.code, 0, status.stderr);
|
|
assert.equal(JSON.parse(status.stdout).installed, true);
|
|
assertManagedTriple((await readKimiConfig(fixture.stablePath)).config);
|
|
} finally {
|
|
await fixture.close();
|
|
}
|
|
});
|
|
|
|
test('legacy-only installation leaves the stable migration target absent', async () => {
|
|
const fixture = await createFixture({
|
|
explicitKimiHome: false,
|
|
stableDetected: false,
|
|
});
|
|
try {
|
|
const result = await fixture.install();
|
|
assert.equal(result.code, 0, result.stderr);
|
|
assertManagedTriple((await readKimiConfig(fixture.legacyPath)).config);
|
|
await assertMissing(fixture.stablePath);
|
|
} finally {
|
|
await fixture.close();
|
|
}
|
|
});
|
|
|
|
test('Kimi parses every selected config before creating runtime files', async () => {
|
|
const fixture = await createFixture({
|
|
stableConfig: userStableConfig,
|
|
legacyConfig: '[malformed',
|
|
});
|
|
try {
|
|
const result = await fixture.install();
|
|
assert.equal(result.code, 1);
|
|
assert.match(result.stderr, /parse kimi-cli legacy hooks config/i);
|
|
assert.equal(await readFile(fixture.stablePath, 'utf-8'), userStableConfig);
|
|
assert.equal(await readFile(fixture.legacyPath, 'utf-8'), '[malformed');
|
|
await assert.rejects(lstat(fixture.agentDir), { code: 'ENOENT' });
|
|
} finally {
|
|
await fixture.close();
|
|
}
|
|
});
|
|
|
|
test('Kimi rejects fields and events outside each official contract', async (t) => {
|
|
for (const [name, stableConfig, legacyConfig, pattern] of [
|
|
[
|
|
'unsupported field',
|
|
'[[hooks]]\nevent = "PreToolUse"\ncommand = "existing"\ncwd = "/tmp"\n',
|
|
undefined,
|
|
/unsupported field "cwd"/i,
|
|
],
|
|
[
|
|
'legacy stable-only event',
|
|
undefined,
|
|
'[[hooks]]\nevent = "Interrupt"\ncommand = "existing"\n',
|
|
/unsupported event "Interrupt"/i,
|
|
],
|
|
[
|
|
'timeout bound',
|
|
'[[hooks]]\nevent = "PreToolUse"\ncommand = "existing"\ntimeout = 601\n',
|
|
undefined,
|
|
/integer from 1 to 600/i,
|
|
],
|
|
]) {
|
|
await t.test(name, async () => {
|
|
const fixture = await createFixture({ stableConfig, legacyConfig });
|
|
try {
|
|
const result = await fixture.install();
|
|
assert.equal(result.code, 1);
|
|
assert.match(result.stderr, pattern);
|
|
await assert.rejects(lstat(fixture.agentDir), { code: 'ENOENT' });
|
|
} finally {
|
|
await fixture.close();
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
test('Kimi migrates exact legacy commands and preserves command lookalikes', async () => {
|
|
const fixture = await createFixture({ legacyDetected: false });
|
|
const lookalike = `${legacyCommand(fixture.guardScriptPath)} --inspect`;
|
|
const source = [
|
|
'[[hooks]]',
|
|
'event = "PreToolUse"',
|
|
`command = ${JSON.stringify(legacyCommand(fixture.guardScriptPath))}`,
|
|
'timeout = 10',
|
|
'',
|
|
'[[hooks]]',
|
|
'event = "PostToolUse"',
|
|
`command = ${JSON.stringify(legacyCommand(fixture.hookScriptPath))}`,
|
|
'timeout = 10',
|
|
'',
|
|
'[[hooks]]',
|
|
'event = "PreToolUse"',
|
|
`command = ${JSON.stringify(lookalike)}`,
|
|
'timeout = 10',
|
|
'',
|
|
].join('\n');
|
|
try {
|
|
await mkdir(path.dirname(fixture.stablePath), { recursive: true });
|
|
await writeFile(fixture.stablePath, source, { encoding: 'utf-8', mode: 0o600 });
|
|
const install = await fixture.install();
|
|
assert.equal(install.code, 0, install.stderr);
|
|
let config = (await readKimiConfig(fixture.stablePath)).config;
|
|
assert.equal(config.hooks.length, 4);
|
|
assert(config.hooks.some((hook) => hook.command === lookalike));
|
|
assertManagedTriple(config);
|
|
|
|
const uninstall = await runPlugin(fixture, 'uninstall', 'agent-1');
|
|
assert.equal(uninstall.code, 0, uninstall.stderr);
|
|
config = (await readKimiConfig(fixture.stablePath)).config;
|
|
assert.deepEqual(config.hooks.map((hook) => ({ ...hook })), [{
|
|
event: 'PreToolUse',
|
|
command: lookalike,
|
|
timeout: 10,
|
|
}]);
|
|
} finally {
|
|
await fixture.close();
|
|
}
|
|
});
|
|
|
|
test('Kimi uninstall preserves user config and removes fully managed configs', async () => {
|
|
const userFixture = await createFixture({
|
|
stableConfig: userStableConfig,
|
|
legacyConfig: userLegacyConfig,
|
|
});
|
|
try {
|
|
assert.equal((await userFixture.install()).code, 0);
|
|
const result = await runPlugin(userFixture, 'uninstall', 'agent-1');
|
|
assert.equal(result.code, 0, result.stderr);
|
|
const stable = await readKimiConfig(userFixture.stablePath);
|
|
const legacy = await readKimiConfig(userFixture.legacyPath);
|
|
assert.match(stable.raw, /# keep stable hook/);
|
|
assert.match(legacy.raw, /# legacy user config/);
|
|
assert.deepEqual(stable.config.hooks.map((hook) => hook.command), ['existing-stable']);
|
|
assert.deepEqual(legacy.config.hooks.map((hook) => hook.command), ['existing-legacy']);
|
|
} finally {
|
|
await userFixture.close();
|
|
}
|
|
|
|
const managedFixture = await createFixture();
|
|
try {
|
|
assert.equal((await managedFixture.install()).code, 0);
|
|
const result = await runPlugin(managedFixture, 'uninstall', 'agent-1');
|
|
assert.equal(result.code, 0, result.stderr);
|
|
await assertMissing(managedFixture.stablePath);
|
|
await assertMissing(managedFixture.legacyPath);
|
|
} finally {
|
|
await managedFixture.close();
|
|
}
|
|
});
|
|
|
|
test('Kimi status requires the complete triple, runtime identity, and private key', async () => {
|
|
const fixture = await createFixture({ legacyDetected: false });
|
|
try {
|
|
assert.equal((await fixture.install()).code, 0);
|
|
let status = await runPlugin(fixture, 'status', null);
|
|
assert.equal(status.code, 0, status.stderr);
|
|
assert.equal(JSON.parse(status.stdout).installed, true);
|
|
|
|
const { config } = await readKimiConfig(fixture.stablePath);
|
|
config.hooks = config.hooks.filter((hook) => hook.event !== 'PostToolUseFailure');
|
|
await writeFile(fixture.stablePath, stringifyToml(config), 'utf-8');
|
|
status = await runPlugin(fixture, 'status', null);
|
|
assert.equal(JSON.parse(status.stdout).installed, false);
|
|
|
|
assert.equal((await fixture.install()).code, 0);
|
|
await writeFile(path.join(fixture.agentDir, 'private.key'), 'invalid');
|
|
status = await runPlugin(fixture, 'status', null);
|
|
assert.equal(status.code, 1);
|
|
assert.match(status.stderr, /private key is invalid/i);
|
|
} finally {
|
|
await fixture.close();
|
|
}
|
|
});
|
|
|
|
test('Kimi installation leaves no transaction files', async () => {
|
|
const fixture = await createFixture();
|
|
try {
|
|
const result = await fixture.install();
|
|
assert.equal(result.code, 0, result.stderr);
|
|
for (const directory of [fixture.agentDir, fixture.kimiHome, fixture.legacyHome]) {
|
|
const names = await readdir(directory);
|
|
assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false, names.join(', '));
|
|
}
|
|
} finally {
|
|
await fixture.close();
|
|
}
|
|
});
|