mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 01:53:21 +08:00
feat: support per-bot workspace switching
This commit is contained in:
parent
adcace3512
commit
44dd50216a
64 changed files with 3878 additions and 712 deletions
318
test/workspace-editor.test.mjs
Normal file
318
test/workspace-editor.test.mjs
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as React from 'react';
|
||||
import TestRenderer from 'react-test-renderer';
|
||||
|
||||
import { WorkspaceEditor } from '../plugin-src/client/workspace-editor.js';
|
||||
import { DiscordSettingsTab } from '../plugin-src/client/channels/discord/index.js';
|
||||
|
||||
const { act, create } = TestRenderer;
|
||||
|
||||
function submitEvent() {
|
||||
return { preventDefault() {} };
|
||||
}
|
||||
|
||||
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 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 shows the current path and saves an edited absolute path', async () => {
|
||||
const saved = [];
|
||||
function Fixture() {
|
||||
const [workspace, setWorkspace] = React.useState('/workspace/current');
|
||||
return React.createElement(WorkspaceEditor, {
|
||||
workspace,
|
||||
async onSave(value) {
|
||||
saved.push(value);
|
||||
setWorkspace(value);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let renderer;
|
||||
await act(async () => { renderer = create(React.createElement(Fixture)); });
|
||||
assert.equal(renderer.root.findByType('code').props.title, '/workspace/current');
|
||||
|
||||
await act(async () => {
|
||||
renderer.root.findByType('button').props.onClick();
|
||||
});
|
||||
const input = renderer.root.findByType('input');
|
||||
assert.equal(input.props.value, '/workspace/current');
|
||||
assert.equal(input.props.maxLength, 4_096);
|
||||
await act(async () => {
|
||||
input.props.onChange({ target: { value: ' /workspace/next project ' } });
|
||||
});
|
||||
await act(async () => {
|
||||
await renderer.root.findByType('form').props.onSubmit(submitEvent());
|
||||
});
|
||||
|
||||
assert.deepEqual(saved, ['/workspace/next project']);
|
||||
assert.equal(renderer.root.findByType('code').props.title, '/workspace/next project');
|
||||
});
|
||||
|
||||
test('WorkspaceEditor keeps editing and presents a rejected workspace error', async () => {
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(WorkspaceEditor, {
|
||||
workspace: '/workspace/current',
|
||||
async onSave() {
|
||||
const error = new Error('工作区路径不存在。');
|
||||
error.code = 'workspace-not-found';
|
||||
throw error;
|
||||
},
|
||||
}));
|
||||
});
|
||||
await act(async () => { renderer.root.findByType('button').props.onClick(); });
|
||||
await act(async () => {
|
||||
renderer.root.findByType('input').props.onChange({ target: { value: '/workspace/missing' } });
|
||||
});
|
||||
await act(async () => {
|
||||
await renderer.root.findByType('form').props.onSubmit(submitEvent());
|
||||
});
|
||||
|
||||
assert.equal(renderer.root.findByProps({ role: 'alert' }).children.join(''), '工作区路径不存在。');
|
||||
assert.equal(renderer.root.findByType('input').props.value, '/workspace/missing');
|
||||
});
|
||||
|
||||
test('WorkspaceEditor rejects a relative path before calling the Host', async () => {
|
||||
let saves = 0;
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(WorkspaceEditor, {
|
||||
workspace: '/workspace/current',
|
||||
async onSave() { saves += 1; },
|
||||
}));
|
||||
});
|
||||
await act(async () => { renderer.root.findByType('button').props.onClick(); });
|
||||
await act(async () => {
|
||||
renderer.root.findByType('input').props.onChange({ target: { value: 'relative/path' } });
|
||||
});
|
||||
await act(async () => {
|
||||
await renderer.root.findByType('form').props.onSubmit(submitEvent());
|
||||
});
|
||||
|
||||
assert.equal(saves, 0);
|
||||
assert.equal(renderer.root.findByProps({ role: 'alert' }).children.join(''), '工作区必须是绝对路径。');
|
||||
});
|
||||
|
||||
test('WorkspaceEditor moves keyboard focus into and back out of edit mode', async () => {
|
||||
let inputFocus = 0;
|
||||
let editFocus = 0;
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(WorkspaceEditor, {
|
||||
workspace: '/workspace/current',
|
||||
async onSave() {},
|
||||
}), {
|
||||
createNodeMock(element) {
|
||||
if (element.type === 'input') return { focus() { inputFocus += 1; } };
|
||||
if (element.props?.className === 'dim-workspaceEdit') {
|
||||
return { focus() { editFocus += 1; } };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
});
|
||||
});
|
||||
await act(async () => { renderer.root.findByType('button').props.onClick(); });
|
||||
assert.equal(inputFocus, 1);
|
||||
const cancel = renderer.root.findAllByType('button')
|
||||
.find((button) => button.children.join('') === '取消');
|
||||
await act(async () => { cancel.props.onClick(); });
|
||||
assert.equal(editFocus, 1);
|
||||
});
|
||||
|
||||
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;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(DiscordSettingsTab, { rpcCall }));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
await act(async () => {
|
||||
intervalCallback();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
await act(async () => {
|
||||
renderer.root.findAllByType('button')
|
||||
.find((button) => button.children.join('') === '修改').props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
renderer.root.findByType('input').props.onChange({ target: { value: '/workspace/new' } });
|
||||
await renderer.root.findByType('form').props.onSubmit(submitEvent());
|
||||
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;
|
||||
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 () => {
|
||||
firstCard.findByType('input').props.onChange({ target: { value: '/workspace/new' } });
|
||||
await firstCard.findByType('form').props.onSubmit(submitEvent());
|
||||
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('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(); });
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue