mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 03:03:24 +08:00
814 lines
27 KiB
JavaScript
814 lines
27 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import test from 'node:test';
|
||
import * as React from 'react';
|
||
import TestRenderer from 'react-test-renderer';
|
||
|
||
import {
|
||
AgentPresetCatalogContext,
|
||
AgentPresetEditor,
|
||
} from '../plugin-src/client/agent-preset.js';
|
||
import {
|
||
WorkspaceDirectoryPickerContext,
|
||
WorkspaceEditor,
|
||
} from '../plugin-src/client/workspace-editor.js';
|
||
import { DiscordSettingsTab } from '../plugin-src/client/channels/discord/index.js';
|
||
import { en, setImTranslator } from '../plugin-src/client/i18n.js';
|
||
|
||
const { act, create } = TestRenderer;
|
||
|
||
function deferred() {
|
||
let resolve;
|
||
const promise = new Promise((onResolve) => { resolve = onResolve; });
|
||
return { promise, resolve };
|
||
}
|
||
|
||
async function flushMicrotasks() {
|
||
for (let index = 0; index < 6; index += 1) await Promise.resolve();
|
||
}
|
||
|
||
function directoryListing(path, childNames = [], { home = '/workspace', truncated = false } = {}) {
|
||
let cursor = '';
|
||
const crumbs = [{ name: '/', path: '/', hidden: false }];
|
||
for (const name of path.split('/').filter(Boolean)) {
|
||
cursor += `/${name}`;
|
||
crumbs.push({ name, path: cursor, hidden: false });
|
||
}
|
||
return {
|
||
path,
|
||
home,
|
||
crumbs,
|
||
entries: childNames.map((name) => ({
|
||
name,
|
||
path: `${path === '/' ? '' : path}/${name}`,
|
||
hidden: name.startsWith('.'),
|
||
})),
|
||
truncated,
|
||
};
|
||
}
|
||
|
||
function nativeUnavailable() {
|
||
const error = new Error('Directory browsing is unavailable');
|
||
error.rpcError = {
|
||
code: 'directory-picker-unavailable',
|
||
message: error.message,
|
||
details: { capability: 'native' },
|
||
};
|
||
return error;
|
||
}
|
||
|
||
function nativeDirectoryPicker(selected) {
|
||
const calls = { list: 0, pick: 0 };
|
||
return {
|
||
calls,
|
||
async listDirectory() {
|
||
calls.list += 1;
|
||
throw nativeUnavailable();
|
||
},
|
||
async pickDirectory() {
|
||
calls.pick += 1;
|
||
return selected;
|
||
},
|
||
};
|
||
}
|
||
|
||
function withDirectoryPicker(element, picker) {
|
||
return React.createElement(
|
||
WorkspaceDirectoryPickerContext.Provider,
|
||
{ value: picker },
|
||
element,
|
||
);
|
||
}
|
||
|
||
function textOf(node) {
|
||
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
||
return node?.children?.map(textOf).join('') ?? '';
|
||
}
|
||
|
||
function buttonNamed(root, name) {
|
||
return root.findAllByType('button').find((button) => textOf(button) === name);
|
||
}
|
||
|
||
function discordSnapshot(workspace) {
|
||
return {
|
||
revision: 1,
|
||
bots: [{
|
||
botId: 'discord_test',
|
||
connected: true,
|
||
state: 'connected',
|
||
workspace,
|
||
bot: { name: 'Harness Bot', username: 'HarnessBot', idMasked: '123•••' },
|
||
health: { summary: 'Discord Gateway 长连接运行正常', lastCheckedAt: Date.now() },
|
||
error: null,
|
||
}],
|
||
};
|
||
}
|
||
|
||
function twoBotDiscordSnapshot(firstWorkspace) {
|
||
const first = discordSnapshot(firstWorkspace).bots[0];
|
||
return {
|
||
revision: 1,
|
||
bots: [
|
||
{ ...first, botId: 'discord_first', bot: { ...first.bot, name: 'First Bot' } },
|
||
{
|
||
...first,
|
||
botId: 'discord_second',
|
||
workspace: '/workspace/second',
|
||
bot: { ...first.bot, name: 'Second Bot' },
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
test('WorkspaceEditor browses from the current path and saves the selected directory', async () => {
|
||
const saved = [];
|
||
const listed = [];
|
||
const picker = {
|
||
async listDirectory(path) {
|
||
listed.push(path);
|
||
if (path === '/workspace/current') {
|
||
return directoryListing(path, ['next project', '.hidden']);
|
||
}
|
||
if (path === '/workspace/current/next project') return directoryListing(path);
|
||
throw new Error(`Unexpected directory: ${path}`);
|
||
},
|
||
async pickDirectory() { throw new Error('native picker should not run'); },
|
||
};
|
||
function Fixture() {
|
||
const [workspace, setWorkspace] = React.useState('/workspace/current');
|
||
return React.createElement(WorkspaceEditor, {
|
||
workspace,
|
||
directoryPicker: picker,
|
||
async onSave(value) {
|
||
saved.push(value);
|
||
setWorkspace(value);
|
||
},
|
||
});
|
||
}
|
||
|
||
const bodyNode = { scrollTop: 0 };
|
||
let renderer;
|
||
await act(async () => {
|
||
renderer = create(React.createElement(Fixture), {
|
||
createNodeMock(element) {
|
||
if (element.props?.className === 'dim-directoryPickerBody') return bodyNode;
|
||
return {};
|
||
},
|
||
});
|
||
});
|
||
assert.equal(renderer.root.findByType('code').props.title, '/workspace/current');
|
||
|
||
await act(async () => {
|
||
renderer.root.findByProps({ className: 'dim-workspaceEdit' }).props.onClick();
|
||
await flushMicrotasks();
|
||
});
|
||
const dialog = renderer.root.findByProps({ role: 'dialog' });
|
||
assert.equal(dialog.props['aria-modal'], 'true');
|
||
assert.equal(
|
||
textOf(renderer.root.findByProps({ id: dialog.props['aria-describedby'] })),
|
||
'切换后会清除这个机器人的旧会话映射。',
|
||
);
|
||
assert.equal(renderer.root.findAllByProps({ title: '/workspace/current/.hidden' }).length, 0);
|
||
bodyNode.scrollTop = 240;
|
||
await act(async () => {
|
||
renderer.root.findByProps({ title: '/workspace/current/next project' }).props.onClick();
|
||
await flushMicrotasks();
|
||
});
|
||
await act(async () => {
|
||
renderer.root.findByProps({ className: 'dim-directoryPickerPrimary' }).props.onClick();
|
||
await flushMicrotasks();
|
||
});
|
||
|
||
assert.deepEqual(listed, ['/workspace/current', '/workspace/current/next project']);
|
||
assert.equal(bodyNode.scrollTop, 0);
|
||
assert.deepEqual(saved, ['/workspace/current/next project']);
|
||
assert.equal(renderer.root.findByType('code').props.title, '/workspace/current/next project');
|
||
});
|
||
|
||
test('WorkspaceEditor keeps the picker open and presents a rejected workspace error', async () => {
|
||
const picker = {
|
||
async listDirectory(path) {
|
||
return path === '/workspace/current'
|
||
? directoryListing(path, ['missing'])
|
||
: directoryListing(path);
|
||
},
|
||
};
|
||
let renderer;
|
||
await act(async () => {
|
||
renderer = create(React.createElement(WorkspaceEditor, {
|
||
workspace: '/workspace/current',
|
||
directoryPicker: picker,
|
||
async onSave() {
|
||
const error = new Error('工作区路径不存在。');
|
||
error.code = 'workspace-not-found';
|
||
throw error;
|
||
},
|
||
}));
|
||
});
|
||
await act(async () => {
|
||
renderer.root.findByProps({ className: 'dim-workspaceEdit' }).props.onClick();
|
||
await flushMicrotasks();
|
||
});
|
||
await act(async () => {
|
||
renderer.root.findByProps({ title: '/workspace/current/missing' }).props.onClick();
|
||
await flushMicrotasks();
|
||
renderer.root.findByProps({ className: 'dim-directoryPickerPrimary' }).props.onClick();
|
||
await flushMicrotasks();
|
||
});
|
||
|
||
assert.equal(textOf(renderer.root.findByProps({ role: 'alert' })), '工作区路径不存在。');
|
||
assert.equal(renderer.root.findByProps({ role: 'dialog' }).props['aria-modal'], 'true');
|
||
assert.equal(renderer.root.findByType('code').props.title, '/workspace/current');
|
||
});
|
||
|
||
test('WorkspaceEditor closes without saving when the current directory is selected', async () => {
|
||
let saves = 0;
|
||
const picker = { async listDirectory(path) { return directoryListing(path); } };
|
||
let renderer;
|
||
await act(async () => {
|
||
renderer = create(React.createElement(WorkspaceEditor, {
|
||
workspace: '/workspace/current',
|
||
directoryPicker: picker,
|
||
async onSave() { saves += 1; },
|
||
}));
|
||
});
|
||
await act(async () => {
|
||
renderer.root.findByProps({ className: 'dim-workspaceEdit' }).props.onClick();
|
||
await flushMicrotasks();
|
||
});
|
||
await act(async () => {
|
||
renderer.root.findByProps({ className: 'dim-directoryPickerPrimary' }).props.onClick();
|
||
await flushMicrotasks();
|
||
});
|
||
|
||
assert.equal(saves, 0);
|
||
assert.equal(renderer.root.findAllByProps({ role: 'dialog' }).length, 0);
|
||
});
|
||
|
||
test('WorkspaceEditor falls back to one native picker without restarting after save', async () => {
|
||
const saved = [];
|
||
const picker = nativeDirectoryPicker('/workspace/native');
|
||
function Fixture() {
|
||
const [workspace, setWorkspace] = React.useState('/workspace/current');
|
||
return React.createElement(WorkspaceEditor, {
|
||
workspace,
|
||
directoryPicker: picker,
|
||
async onSave(value) {
|
||
saved.push(value);
|
||
setWorkspace(value);
|
||
},
|
||
});
|
||
}
|
||
|
||
let renderer;
|
||
await act(async () => { renderer = create(React.createElement(Fixture)); });
|
||
await act(async () => {
|
||
renderer.root.findByProps({ className: 'dim-workspaceEdit' }).props.onClick();
|
||
await flushMicrotasks();
|
||
});
|
||
|
||
assert.deepEqual(saved, ['/workspace/native']);
|
||
assert.deepEqual(picker.calls, { list: 1, pick: 1 });
|
||
assert.equal(renderer.root.findByType('code').props.title, '/workspace/native');
|
||
assert.equal(renderer.root.findAllByProps({ role: 'dialog' }).length, 0);
|
||
});
|
||
|
||
test('WorkspaceEditor treats native picker cancellation as cancellation, not an error', async () => {
|
||
let saves = 0;
|
||
const picker = nativeDirectoryPicker(null);
|
||
let renderer;
|
||
await act(async () => {
|
||
renderer = create(React.createElement(WorkspaceEditor, {
|
||
workspace: '/workspace/current',
|
||
directoryPicker: picker,
|
||
async onSave() { saves += 1; },
|
||
}));
|
||
});
|
||
await act(async () => {
|
||
renderer.root.findByProps({ className: 'dim-workspaceEdit' }).props.onClick();
|
||
await flushMicrotasks();
|
||
});
|
||
|
||
assert.equal(saves, 0);
|
||
assert.deepEqual(picker.calls, { list: 1, pick: 1 });
|
||
assert.equal(renderer.root.findAllByProps({ role: 'alert' }).length, 0);
|
||
assert.equal(renderer.root.findAllByProps({ role: 'dialog' }).length, 0);
|
||
});
|
||
|
||
test('WorkspaceEditor falls back to the Host home when the saved path is unreadable', async () => {
|
||
const listed = [];
|
||
const saved = [];
|
||
const picker = {
|
||
async listDirectory(path) {
|
||
listed.push(path);
|
||
if (path === '/workspace/gone') {
|
||
const error = new Error('missing');
|
||
error.rpcError = { code: 'directory-unreadable', message: 'missing', details: { path } };
|
||
throw error;
|
||
}
|
||
return directoryListing('/workspace', ['projects']);
|
||
},
|
||
};
|
||
let renderer;
|
||
await act(async () => {
|
||
renderer = create(React.createElement(WorkspaceEditor, {
|
||
workspace: '/workspace/gone',
|
||
directoryPicker: picker,
|
||
async onSave(value) { saved.push(value); },
|
||
}));
|
||
});
|
||
await act(async () => {
|
||
renderer.root.findByProps({ className: 'dim-workspaceEdit' }).props.onClick();
|
||
await flushMicrotasks();
|
||
});
|
||
|
||
assert.deepEqual(listed, ['/workspace/gone', undefined]);
|
||
await act(async () => {
|
||
renderer.root.findByProps({ className: 'dim-directoryPickerPrimary' }).props.onClick();
|
||
await flushMicrotasks();
|
||
});
|
||
assert.deepEqual(saved, ['/workspace']);
|
||
});
|
||
|
||
test('WorkspaceEditor moves keyboard focus into and back out of the picker', async () => {
|
||
let dialogFocus = 0;
|
||
let editFocus = 0;
|
||
const picker = { async listDirectory(path) { return directoryListing(path); } };
|
||
let renderer;
|
||
await act(async () => {
|
||
renderer = create(React.createElement(WorkspaceEditor, {
|
||
workspace: '/workspace/current',
|
||
directoryPicker: picker,
|
||
async onSave() {},
|
||
}), {
|
||
createNodeMock(element) {
|
||
if (element.props?.className === 'dim-directoryPicker') {
|
||
return { focus() { dialogFocus += 1; } };
|
||
}
|
||
if (element.props?.className === 'dim-workspaceEdit') {
|
||
return { focus() { editFocus += 1; } };
|
||
}
|
||
return {};
|
||
},
|
||
});
|
||
});
|
||
await act(async () => {
|
||
renderer.root.findByProps({ className: 'dim-workspaceEdit' }).props.onClick();
|
||
await flushMicrotasks();
|
||
});
|
||
assert.equal(dialogFocus, 1);
|
||
await act(async () => {
|
||
buttonNamed(renderer.root, '取消').props.onClick();
|
||
await flushMicrotasks();
|
||
});
|
||
assert.equal(editFocus, 1);
|
||
});
|
||
|
||
test('WorkspaceEditor never translates Host filesystem names in the English UI', async (t) => {
|
||
setImTranslator((key) => en[key] ?? key);
|
||
t.after(() => setImTranslator(null));
|
||
const picker = {
|
||
async listDirectory(path) { return directoryListing(path, ['微信']); },
|
||
};
|
||
let renderer;
|
||
await act(async () => {
|
||
renderer = create(React.createElement(WorkspaceEditor, {
|
||
workspace: '/workspace/current',
|
||
directoryPicker: picker,
|
||
async onSave() {},
|
||
}));
|
||
await flushMicrotasks();
|
||
});
|
||
await act(async () => {
|
||
renderer.root.findByProps({ className: 'dim-workspaceEdit' }).props.onClick();
|
||
await flushMicrotasks();
|
||
});
|
||
|
||
const directory = renderer.root.findByProps({ title: '/workspace/current/微信' });
|
||
assert.equal(textOf(directory), '微信');
|
||
assert.doesNotMatch(textOf(directory), /WeChat/);
|
||
await act(async () => { renderer.unmount(); });
|
||
});
|
||
|
||
test('a status response started before saving cannot restore the old workspace', async (t) => {
|
||
const previousWindow = globalThis.window;
|
||
let intervalCallback;
|
||
globalThis.window = {
|
||
setInterval(callback) { intervalCallback = callback; return 1; },
|
||
clearInterval() {},
|
||
};
|
||
t.after(() => {
|
||
if (previousWindow === undefined) delete globalThis.window;
|
||
else globalThis.window = previousWindow;
|
||
});
|
||
const staleStatus = deferred();
|
||
let statusCalls = 0;
|
||
const rpcCall = async (endpoint) => {
|
||
if (endpoint === 'connection.status') {
|
||
statusCalls += 1;
|
||
if (statusCalls === 1) return { ok: true, value: discordSnapshot('/workspace/current') };
|
||
if (statusCalls === 2) return staleStatus.promise;
|
||
return { ok: true, value: discordSnapshot('/workspace/new') };
|
||
}
|
||
if (endpoint === 'bot.workspace.set') {
|
||
return { ok: true, value: discordSnapshot('/workspace/new') };
|
||
}
|
||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||
};
|
||
|
||
let renderer;
|
||
const picker = nativeDirectoryPicker('/workspace/new');
|
||
await act(async () => {
|
||
renderer = create(withDirectoryPicker(
|
||
React.createElement(DiscordSettingsTab, { rpcCall }),
|
||
picker,
|
||
));
|
||
await flushMicrotasks();
|
||
});
|
||
await act(async () => {
|
||
intervalCallback();
|
||
await flushMicrotasks();
|
||
});
|
||
await act(async () => {
|
||
buttonNamed(renderer.root, '选择目录').props.onClick();
|
||
await flushMicrotasks();
|
||
});
|
||
assert.equal(renderer.root.findByType('code').props.title, '/workspace/new');
|
||
|
||
await act(async () => {
|
||
staleStatus.resolve({ ok: true, value: discordSnapshot('/workspace/old') });
|
||
await flushMicrotasks();
|
||
});
|
||
assert.equal(renderer.root.findByType('code').props.title, '/workspace/new');
|
||
await act(async () => { renderer.unmount(); });
|
||
});
|
||
|
||
test('an older reconnect snapshot from another bot cannot restore a saved workspace', async (t) => {
|
||
const previousWindow = globalThis.window;
|
||
globalThis.window = { setInterval() { return 1; }, clearInterval() {} };
|
||
t.after(() => {
|
||
if (previousWindow === undefined) delete globalThis.window;
|
||
else globalThis.window = previousWindow;
|
||
});
|
||
const staleReconnect = deferred();
|
||
let statusCalls = 0;
|
||
const rpcCall = async (endpoint) => {
|
||
if (endpoint === 'connection.status') {
|
||
statusCalls += 1;
|
||
return {
|
||
ok: true,
|
||
value: twoBotDiscordSnapshot(statusCalls === 1 ? '/workspace/current' : '/workspace/new'),
|
||
};
|
||
}
|
||
if (endpoint === 'bot.reconnect') return staleReconnect.promise;
|
||
if (endpoint === 'bot.workspace.set') {
|
||
return { ok: true, value: twoBotDiscordSnapshot('/workspace/new') };
|
||
}
|
||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||
};
|
||
|
||
let renderer;
|
||
const picker = nativeDirectoryPicker('/workspace/new');
|
||
await act(async () => {
|
||
renderer = create(withDirectoryPicker(
|
||
React.createElement(DiscordSettingsTab, { rpcCall }),
|
||
picker,
|
||
));
|
||
await flushMicrotasks();
|
||
});
|
||
const firstCard = renderer.root.findByProps({ 'data-bot-id': 'discord_first' });
|
||
const secondCard = renderer.root.findByProps({ 'data-bot-id': 'discord_second' });
|
||
await act(async () => {
|
||
secondCard.findAllByType('button')
|
||
.find((button) => button.children.join('') === '检查连接').props.onClick();
|
||
await flushMicrotasks();
|
||
});
|
||
await act(async () => {
|
||
buttonNamed(firstCard, '选择目录').props.onClick();
|
||
await flushMicrotasks();
|
||
});
|
||
|
||
staleReconnect.resolve({ ok: true, value: twoBotDiscordSnapshot('/workspace/old') });
|
||
await act(async () => { await flushMicrotasks(); });
|
||
assert.equal(
|
||
renderer.root.findByProps({ 'data-bot-id': 'discord_first' }).findByType('code').props.title,
|
||
'/workspace/new',
|
||
);
|
||
await act(async () => { renderer.unmount(); });
|
||
});
|
||
|
||
test('connection check requests a test message and shows its delivery result', async (t) => {
|
||
const previousWindow = globalThis.window;
|
||
globalThis.window = { setInterval() { return 1; }, clearInterval() {} };
|
||
t.after(() => {
|
||
if (previousWindow === undefined) delete globalThis.window;
|
||
else globalThis.window = previousWindow;
|
||
});
|
||
const calls = [];
|
||
const snapshot = discordSnapshot('/workspace/current');
|
||
const rpcCall = async (endpoint, payload) => {
|
||
calls.push({ endpoint, payload });
|
||
if (endpoint === 'connection.status') return { ok: true, value: snapshot };
|
||
if (endpoint === 'bot.reconnect') {
|
||
return { ok: true, value: { ...snapshot, testMessage: { sent: true } } };
|
||
}
|
||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||
};
|
||
|
||
let renderer;
|
||
await act(async () => {
|
||
renderer = create(React.createElement(DiscordSettingsTab, { rpcCall }));
|
||
await flushMicrotasks();
|
||
});
|
||
const card = renderer.root.findByProps({ 'data-bot-id': 'discord_test' });
|
||
await act(async () => {
|
||
buttonNamed(card, '检查连接').props.onClick();
|
||
await flushMicrotasks();
|
||
});
|
||
|
||
assert.deepEqual(calls.find((call) => call.endpoint === 'bot.reconnect')?.payload, {
|
||
botId: 'discord_test',
|
||
sendTest: true,
|
||
});
|
||
assert.equal(
|
||
textOf(renderer.root.findByProps({ role: 'status' })),
|
||
'测试消息已发送,请到对应机器人会话中确认。',
|
||
);
|
||
await act(async () => { renderer.unmount(); });
|
||
});
|
||
|
||
test('shared token target-unavailable feedback asks for any direct message', async (t) => {
|
||
const previousWindow = globalThis.window;
|
||
globalThis.window = { setInterval() { return 1; }, clearInterval() {} };
|
||
setImTranslator((key) => en[key] ?? key);
|
||
t.after(() => {
|
||
setImTranslator(null);
|
||
if (previousWindow === undefined) delete globalThis.window;
|
||
else globalThis.window = previousWindow;
|
||
});
|
||
const snapshot = discordSnapshot('/workspace/current');
|
||
const rpcCall = async (endpoint) => {
|
||
if (endpoint === 'connection.status') return { ok: true, value: snapshot };
|
||
if (endpoint === 'bot.reconnect') {
|
||
return {
|
||
ok: true,
|
||
value: {
|
||
...snapshot,
|
||
testMessage: { sent: false, code: 'test-target-unavailable' },
|
||
},
|
||
};
|
||
}
|
||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||
};
|
||
|
||
let renderer;
|
||
await act(async () => {
|
||
renderer = create(React.createElement(DiscordSettingsTab, { rpcCall }));
|
||
await flushMicrotasks();
|
||
});
|
||
const card = renderer.root.findByProps({ 'data-bot-id': 'discord_test' });
|
||
await act(async () => {
|
||
buttonNamed(card, 'Check connection').props.onClick();
|
||
await flushMicrotasks();
|
||
});
|
||
|
||
const notice = textOf(renderer.root.findByProps({ role: 'status' }));
|
||
assert.equal(
|
||
notice,
|
||
'Connection check completed. The bot has not received a direct message it can use for testing.',
|
||
);
|
||
assert.doesNotMatch(notice, /\/status|[\p{Script=Han}]/u);
|
||
await act(async () => { renderer.unmount(); });
|
||
});
|
||
|
||
test('shared token connection failures render a fixed English-safe notice', async (t) => {
|
||
const previousWindow = globalThis.window;
|
||
globalThis.window = { setInterval() { return 1; }, clearInterval() {} };
|
||
setImTranslator((key) => en[key] ?? key);
|
||
t.after(() => {
|
||
setImTranslator(null);
|
||
if (previousWindow === undefined) delete globalThis.window;
|
||
else globalThis.window = previousWindow;
|
||
});
|
||
const snapshot = discordSnapshot('/workspace/current');
|
||
const rpcCall = async (endpoint) => {
|
||
if (endpoint === 'connection.status') return { ok: true, value: snapshot };
|
||
if (endpoint === 'bot.reconnect') {
|
||
return {
|
||
ok: false,
|
||
error: { code: 'discord-operation-failed', message: 'Discord 操作失败,请稍后重试。' },
|
||
};
|
||
}
|
||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||
};
|
||
|
||
let renderer;
|
||
await act(async () => {
|
||
renderer = create(React.createElement(DiscordSettingsTab, { rpcCall }));
|
||
await flushMicrotasks();
|
||
});
|
||
const card = renderer.root.findByProps({ 'data-bot-id': 'discord_test' });
|
||
await act(async () => {
|
||
buttonNamed(card, 'Check connection').props.onClick();
|
||
await flushMicrotasks();
|
||
});
|
||
|
||
const notice = textOf(renderer.root.findByProps({ role: 'status' }));
|
||
assert.equal(notice, 'Connection check failed. Try again later.');
|
||
assert.doesNotMatch(notice, /[\p{Script=Han}]/u);
|
||
await act(async () => { renderer.unmount(); });
|
||
});
|
||
|
||
test('an older reconnect snapshot cannot resurrect a bot deleted by a newer mutation', async (t) => {
|
||
const previousWindow = globalThis.window;
|
||
globalThis.window = { setInterval() { return 1; }, clearInterval() {} };
|
||
t.after(() => {
|
||
if (previousWindow === undefined) delete globalThis.window;
|
||
else globalThis.window = previousWindow;
|
||
});
|
||
const staleReconnect = deferred();
|
||
const initialSnapshot = twoBotDiscordSnapshot('/workspace/first');
|
||
const deletedSnapshot = { ...initialSnapshot, bots: initialSnapshot.bots.slice(1) };
|
||
let statusCalls = 0;
|
||
const rpcCall = async (endpoint) => {
|
||
if (endpoint === 'connection.status') {
|
||
statusCalls += 1;
|
||
return { ok: true, value: statusCalls === 1 ? initialSnapshot : deletedSnapshot };
|
||
}
|
||
if (endpoint === 'bot.reconnect') return staleReconnect.promise;
|
||
if (endpoint === 'bot.delete') return { ok: true, value: deletedSnapshot };
|
||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||
};
|
||
|
||
let renderer;
|
||
await act(async () => {
|
||
renderer = create(React.createElement(DiscordSettingsTab, { rpcCall }));
|
||
await flushMicrotasks();
|
||
});
|
||
const firstCard = renderer.root.findByProps({ 'data-bot-id': 'discord_first' });
|
||
const secondCard = renderer.root.findByProps({ 'data-bot-id': 'discord_second' });
|
||
await act(async () => {
|
||
secondCard.findAllByType('button')
|
||
.find((button) => button.children.join('') === '检查连接').props.onClick();
|
||
await flushMicrotasks();
|
||
});
|
||
await act(async () => {
|
||
firstCard.findAllByType('button')
|
||
.find((button) => button.children.join('') === '移除接入').props.onClick();
|
||
});
|
||
await act(async () => {
|
||
await firstCard.findAllByType('button')
|
||
.find((button) => button.children.join('') === '确认移除接入').props.onClick();
|
||
await flushMicrotasks();
|
||
});
|
||
assert.equal(renderer.root.findAllByProps({ 'data-bot-id': 'discord_first' }).length, 0);
|
||
|
||
staleReconnect.resolve({ ok: true, value: initialSnapshot });
|
||
await act(async () => { await flushMicrotasks(); });
|
||
assert.equal(renderer.root.findAllByProps({ 'data-bot-id': 'discord_first' }).length, 0);
|
||
await act(async () => { renderer.unmount(); });
|
||
});
|
||
|
||
const PRESET_CATALOG = {
|
||
defaultId: 'default',
|
||
items: [
|
||
{ id: 'coding', label: 'Coding' },
|
||
{ id: 'default', label: 'Default' },
|
||
],
|
||
};
|
||
|
||
function optionValues(select) {
|
||
return select.children.map((option) => option.props.value);
|
||
}
|
||
|
||
test('AgentPresetEditor lists Host presets and moves its session guidance into accessible help', () => {
|
||
const renderer = create(React.createElement(
|
||
AgentPresetCatalogContext.Provider,
|
||
{ value: PRESET_CATALOG },
|
||
React.createElement(AgentPresetEditor, {
|
||
agentPreset: 'coding',
|
||
onSave() {},
|
||
}),
|
||
));
|
||
const select = renderer.root.findByProps({ className: 'dim-presetSelect' });
|
||
assert.equal(select.props.value, 'coding');
|
||
assert.deepEqual(optionValues(select), ['', 'coding', 'default']);
|
||
assert.equal(textOf(select.children[0]), '跟随 Host 默认');
|
||
assert.equal(textOf(select.children[1]), 'Coding(coding)');
|
||
const helpButton = renderer.root.findByProps({
|
||
'aria-label': '查看 Agent Preset 说明',
|
||
});
|
||
const tooltip = renderer.root.findByProps({ role: 'tooltip' });
|
||
assert.equal(helpButton.props.type, 'button');
|
||
assert.ok(tooltip.props.id);
|
||
assert.equal(helpButton.props['aria-describedby'], tooltip.props.id);
|
||
assert.equal(
|
||
textOf(tooltip),
|
||
'只影响新建会话;若当前聊天已有会话,先发送 /new,再发送普通消息生效。',
|
||
);
|
||
assert.equal(renderer.root.findAllByType('small').length, 0);
|
||
renderer.unmount();
|
||
});
|
||
|
||
test('AgentPresetEditor marks a removed current preset and still allows clearing it', async () => {
|
||
const saved = [];
|
||
const renderer = create(React.createElement(
|
||
AgentPresetCatalogContext.Provider,
|
||
{ value: PRESET_CATALOG },
|
||
React.createElement(AgentPresetEditor, {
|
||
agentPreset: 'removed-preset',
|
||
onSave(value) { saved.push(value); },
|
||
}),
|
||
));
|
||
const select = renderer.root.findByProps({ className: 'dim-presetSelect' });
|
||
assert.equal(select.props.value, 'removed-preset');
|
||
assert.deepEqual(optionValues(select), ['', 'coding', 'default', 'removed-preset']);
|
||
assert.equal(textOf(select.children[3]), 'removed-preset(已不可用)');
|
||
assert.equal(
|
||
textOf(renderer.root.findByProps({ role: 'status' })),
|
||
'当前 Agent Preset 已不可用,请选择其他 Preset 或跟随 Host 默认。',
|
||
);
|
||
|
||
await act(async () => {
|
||
select.props.onChange({ target: { value: '' } });
|
||
await flushMicrotasks();
|
||
});
|
||
assert.deepEqual(saved, [null]);
|
||
renderer.unmount();
|
||
});
|
||
|
||
test('AgentPresetEditor saves a selected preset and can follow the Host default', async () => {
|
||
const saved = [];
|
||
function Harness() {
|
||
const [agentPreset, setAgentPreset] = React.useState('');
|
||
return React.createElement(AgentPresetEditor, {
|
||
agentPreset,
|
||
onSave(value) {
|
||
saved.push(value);
|
||
setAgentPreset(value ?? '');
|
||
},
|
||
});
|
||
}
|
||
const renderer = create(React.createElement(
|
||
AgentPresetCatalogContext.Provider,
|
||
{ value: PRESET_CATALOG },
|
||
React.createElement(Harness),
|
||
));
|
||
const select = renderer.root.findByProps({ className: 'dim-presetSelect' });
|
||
await act(async () => {
|
||
select.props.onChange({ target: { value: 'coding' } });
|
||
await flushMicrotasks();
|
||
});
|
||
await act(async () => {
|
||
renderer.root.findByProps({ className: 'dim-presetSelect' })
|
||
.props.onChange({ target: { value: '' } });
|
||
await flushMicrotasks();
|
||
});
|
||
assert.deepEqual(saved, ['coding', null]);
|
||
renderer.unmount();
|
||
});
|
||
|
||
test('Discord settings save an Agent Preset through bot.preset.set', async (t) => {
|
||
const previousWindow = globalThis.window;
|
||
globalThis.window = { setInterval() { return 1; }, clearInterval() {} };
|
||
t.after(() => {
|
||
if (previousWindow === undefined) delete globalThis.window;
|
||
else globalThis.window = previousWindow;
|
||
});
|
||
const snapshot = {
|
||
...discordSnapshot('/workspace/current'),
|
||
agentPresetCatalog: PRESET_CATALOG,
|
||
};
|
||
const calls = [];
|
||
const rpcCall = async (endpoint, payload) => {
|
||
calls.push({ endpoint, payload });
|
||
if (endpoint === 'connection.status') return { ok: true, value: snapshot };
|
||
if (endpoint === 'bot.preset.set') {
|
||
return {
|
||
ok: true,
|
||
value: {
|
||
...snapshot,
|
||
bots: [{ ...snapshot.bots[0], agentPreset: payload.agentPreset }],
|
||
},
|
||
};
|
||
}
|
||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||
};
|
||
|
||
let renderer;
|
||
await act(async () => {
|
||
renderer = create(React.createElement(DiscordSettingsTab, { rpcCall }));
|
||
await flushMicrotasks();
|
||
});
|
||
const card = renderer.root.findByProps({ 'data-bot-id': 'discord_test' });
|
||
await act(async () => {
|
||
card.findByProps({ className: 'dim-presetSelect' })
|
||
.props.onChange({ target: { value: 'coding' } });
|
||
await flushMicrotasks();
|
||
});
|
||
|
||
assert.deepEqual(calls.find((call) => call.endpoint === 'bot.preset.set')?.payload, {
|
||
botId: 'discord_test',
|
||
agentPreset: 'coding',
|
||
});
|
||
await act(async () => { renderer.unmount(); });
|
||
});
|