mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 21:23:19 +08:00
fix(feishu): harden interactive card flows
This commit is contained in:
parent
5b33249599
commit
9d1dc71794
6 changed files with 1198 additions and 609 deletions
|
|
@ -8,6 +8,7 @@ import { join } from 'node:path';
|
|||
import { FeishuHarnessBridge } from '../../../src/channels/feishu/bridge.mjs';
|
||||
import { DEFAULT_IMAGE_PROMPT } from '../../../src/channels/shared/image-prompt.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
import { setImHostLanguage } from '../../../src/channels/shared/i18n.mjs';
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
OutboundArtifactRegistry,
|
||||
|
|
@ -96,6 +97,97 @@ function textClient(sendText) {
|
|||
};
|
||||
}
|
||||
|
||||
test('English Feishu status responses contain no bridge-level Chinese fallbacks', async () => {
|
||||
const fixture = stateFixture();
|
||||
const textSent = [];
|
||||
const cardSent = [];
|
||||
const patches = [];
|
||||
const harness = {
|
||||
ensureRunning: async () => true,
|
||||
currentWorkspace: () => null,
|
||||
agentPresetSettings: async () => ({
|
||||
agentPreset: null,
|
||||
agentPresetCatalog: {
|
||||
defaultId: null,
|
||||
items: [],
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
setImHostLanguage('en');
|
||||
try {
|
||||
const textBridge = new FeishuHarnessBridge({
|
||||
client: textClient(async (outgoing) => textSent.push(outgoing.text)),
|
||||
channel: {},
|
||||
harness,
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||||
});
|
||||
await textBridge.accept(event('english-status-text', '/status', {
|
||||
senderOpenId: 'ou_owner',
|
||||
}));
|
||||
|
||||
const cardBridge = new FeishuHarnessBridge({
|
||||
client: cardClient(
|
||||
async (outgoing) => cardSent.push(outgoing),
|
||||
async (request) => patches.push(request),
|
||||
),
|
||||
channel: {},
|
||||
harness,
|
||||
state: stateFixture().state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||||
});
|
||||
await cardBridge.accept(event('english-status-menu', '/m', {
|
||||
senderOpenId: 'ou_owner',
|
||||
}));
|
||||
await cardBridge.onCardAction(cardActionEvent('om_card_1', 'status', 'ou_owner'));
|
||||
|
||||
assert.equal(textSent.length, 1);
|
||||
assert.doesNotMatch(textSent[0], /[\u3400-\u9fff]/u);
|
||||
assert.equal(cardSent.length, 1);
|
||||
assert.equal(patches.length, 1);
|
||||
assert.doesNotMatch(patches[0].data.content, /[\u3400-\u9fff]/u);
|
||||
} finally {
|
||||
setImHostLanguage('zh');
|
||||
}
|
||||
});
|
||||
|
||||
test('text new-session waits until the active turn is finished before clearing the binding', async () => {
|
||||
const fixture = stateFixture([['p2p:ou_owner', 'session-active']]);
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
const sent = [];
|
||||
const { harness } = activeTurnHarness();
|
||||
harness.ask = async () => {
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
return 'task finished';
|
||||
};
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async (outgoing) => sent.push(outgoing.text)),
|
||||
channel: {},
|
||||
harness,
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||||
});
|
||||
|
||||
const turn = bridge.accept(event('new-active-turn', 'run a task', {
|
||||
senderOpenId: 'ou_owner',
|
||||
}));
|
||||
await started.promise;
|
||||
await bridge.accept(event('new-while-active', '/new', { senderOpenId: 'ou_owner' }));
|
||||
assert.equal(fixture.sessions.get('p2p:ou_owner'), 'session-active');
|
||||
assert.match(sent.at(-1), /当前任务仍在运行/);
|
||||
|
||||
release.resolve();
|
||||
await turn;
|
||||
await bridge.accept(event('new-after-active', '/new', { senderOpenId: 'ou_owner' }));
|
||||
assert.equal(fixture.sessions.has('p2p:ou_owner'), false);
|
||||
});
|
||||
|
||||
async function committedArtifact(t, fileName, content, suffix = '') {
|
||||
const workspace = await mkdtemp(join(tmpdir(), `dsh-im-feishu-artifact-${suffix}`));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
|
|
@ -2558,9 +2650,9 @@ test('bridge does not expose internal error details in a Feishu failure reply',
|
|||
|
||||
// ── Interactive cards: menus, session lists, workspace lists ───────────────
|
||||
|
||||
function cardClient(onSend) {
|
||||
function cardClient(onSend, onPatch = null) {
|
||||
let sequence = 0;
|
||||
return {
|
||||
const client = {
|
||||
im: { v1: { message: { create: async (request) => {
|
||||
const outgoing = {
|
||||
chatId: request.data.receive_id,
|
||||
|
|
@ -2574,6 +2666,12 @@ function cardClient(onSend) {
|
|||
return { code: 0, data: { message_id: `om_card_${sequence}` } };
|
||||
} } } },
|
||||
};
|
||||
if (typeof onPatch === 'function') {
|
||||
client.im.v1.message.patch = async (request) => (
|
||||
await onPatch(request) ?? { code: 0 }
|
||||
);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
function cardActionEvent(messageId, action, operatorOpenId) {
|
||||
|
|
@ -2599,6 +2697,21 @@ function buttonsFromCard(content) {
|
|||
return buttons;
|
||||
}
|
||||
|
||||
function selectsFromCard(content) {
|
||||
const selects = [];
|
||||
const visit = (value) => {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) visit(item);
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== 'object') return;
|
||||
if (value.tag === 'select_static') selects.push(value);
|
||||
for (const child of Object.values(value)) visit(child);
|
||||
};
|
||||
visit(content.body?.elements);
|
||||
return selects;
|
||||
}
|
||||
|
||||
function callbackAction(button) {
|
||||
return button.behaviors?.find((behavior) => behavior?.type === 'callback')?.value?.action;
|
||||
}
|
||||
|
|
@ -2702,6 +2815,174 @@ test('card buttons honor the wildcard sender allowlist', async () => {
|
|||
assert.equal(sent.length, 2, 'wildcard access must apply to card callbacks too');
|
||||
});
|
||||
|
||||
test('card refresh updates the callback message instead of a newer card', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const patches = [];
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: cardClient(
|
||||
async (outgoing) => sent.push(outgoing),
|
||||
async (request) => patches.push(request),
|
||||
),
|
||||
channel: {},
|
||||
harness: sessionsHarness(3),
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||||
});
|
||||
|
||||
await bridge.accept(event('menu-first', '/m', { senderOpenId: 'ou_owner' }));
|
||||
await bridge.waitForIdle();
|
||||
await bridge.accept(event('menu-second', '/m', { senderOpenId: 'ou_owner' }));
|
||||
await bridge.waitForIdle();
|
||||
assert.equal(cards(sent).length, 2);
|
||||
|
||||
await bridge.onCardAction(cardActionEvent('om_card_1', 'archive_toggle', 'ou_owner'));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.equal(patches.length, 1);
|
||||
assert.equal(patches[0].path.message_id, 'om_card_1');
|
||||
assert.equal(typeof patches[0].data.content, 'string');
|
||||
assert.equal(cards(sent).length, 2, 'a successful refresh must not create another card');
|
||||
});
|
||||
|
||||
test('a Feishu PATCH business error falls back to a new card', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: cardClient(
|
||||
async (outgoing) => sent.push(outgoing),
|
||||
async () => ({ code: 230099, msg: 'parse json error' }),
|
||||
),
|
||||
channel: {},
|
||||
harness: sessionsHarness(1),
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(event('menu-patch-error', '/m', { senderOpenId: 'ou_owner' }));
|
||||
await bridge.onCardAction(cardActionEvent('om_card_1', 'archive_toggle', 'ou_owner'));
|
||||
|
||||
assert.equal(cards(sent).length, 2, 'business failure must create a usable replacement card');
|
||||
});
|
||||
|
||||
test('an unbound menu leaves recent sessions unselected', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: cardClient(async (outgoing) => sent.push(outgoing)),
|
||||
channel: {},
|
||||
harness: sessionsHarness(3),
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||||
});
|
||||
|
||||
await bridge.accept(event('menu-unbound', '/m', { senderOpenId: 'ou_owner' }));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
const sessionPick = selectsFromCard(cards(sent).at(-1).content)
|
||||
.find((select) => select.name === 'session_pick');
|
||||
assert.ok(sessionPick, 'recent sessions should remain selectable');
|
||||
assert.equal(sessionPick.initial_index, 0, 'no session is selected until the user binds one');
|
||||
assert.deepEqual(
|
||||
sessionPick.options.map((option) => option.value),
|
||||
['session-01', 'session-02', 'session-03'],
|
||||
);
|
||||
assert.equal(
|
||||
sessionPick.options.some((option) => option.text?.content?.includes('✓')),
|
||||
false,
|
||||
'an unbound chat must not display a fake current session',
|
||||
);
|
||||
});
|
||||
|
||||
test('compact card action contains session lookup failures', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
let failSessionLookup = false;
|
||||
const sessionFor = fixture.state.sessionFor;
|
||||
fixture.state.sessionFor = (key) => {
|
||||
if (failSessionLookup) {
|
||||
throw new Error('secret-shaped compact detail /private/path');
|
||||
}
|
||||
return sessionFor(key);
|
||||
};
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: cardClient(async (outgoing) => sent.push(outgoing)),
|
||||
channel: {},
|
||||
harness: sessionsHarness(1),
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(event('compact-card-open', '/m', { senderOpenId: 'ou_owner' }));
|
||||
await bridge.waitForIdle();
|
||||
failSessionLookup = true;
|
||||
await bridge.onCardAction(cardActionEvent('om_card_1', 'compact', 'ou_owner'));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
const replies = sent
|
||||
.filter((message) => message.msgType === 'text')
|
||||
.map((message) => JSON.parse(message.content).text);
|
||||
assert.equal(replies.length, 1);
|
||||
assert.match(replies[0], /上下文压缩失败,请稍后重试/);
|
||||
assert.doesNotMatch(replies[0], /secret-shaped|private\/path|compactCommand/);
|
||||
});
|
||||
|
||||
test('preset card selection does not expose internal update errors', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const updates = [];
|
||||
const catalog = {
|
||||
defaultId: 'preset-one',
|
||||
items: [
|
||||
{ id: 'preset-one', label: 'Preset One' },
|
||||
{ id: 'preset-two', label: 'Preset Two' },
|
||||
],
|
||||
};
|
||||
const harness = {
|
||||
...sessionsHarness(1),
|
||||
agentPresetSettings: async () => ({
|
||||
agentPreset: 'preset-one',
|
||||
agentPresetCatalog: { ...catalog, items: catalog.items.map((item) => ({ ...item })) },
|
||||
}),
|
||||
updateAgentPreset: async (presetId) => {
|
||||
updates.push(presetId);
|
||||
throw new Error('secret-shaped preset detail /private/path');
|
||||
},
|
||||
};
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: cardClient(async (outgoing) => sent.push(outgoing)),
|
||||
channel: {},
|
||||
harness,
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(event('preset-card-open', '/m', { senderOpenId: 'ou_owner' }));
|
||||
await bridge.waitForIdle();
|
||||
await bridge.onCardAction({
|
||||
operator: { open_id: 'ou_owner' },
|
||||
action: { value: { action: 'preset_pick' }, option: 'preset-two' },
|
||||
context: { open_message_id: 'om_card_1' },
|
||||
});
|
||||
await bridge.waitForIdle();
|
||||
|
||||
const replies = sent
|
||||
.filter((message) => message.msgType === 'text')
|
||||
.map((message) => JSON.parse(message.content).text);
|
||||
assert.deepEqual(updates, ['preset-two']);
|
||||
assert.equal(replies.length, 1);
|
||||
assert.match(replies[0], /失败,请稍后重试/);
|
||||
assert.doesNotMatch(replies[0], /secret-shaped|private\/path/);
|
||||
});
|
||||
|
||||
function cards(messages) { return messages.filter((m) => m.msgType === 'interactive'); }
|
||||
|
||||
test('session list paginates by page number across 25 sessions', async () => {
|
||||
|
|
@ -3462,24 +3743,31 @@ test('archived sessions are hidden by default; /archived on reveals them', async
|
|||
|
||||
function activeTurnHarness({ stopped = true, steered = true } = {}) {
|
||||
const calls = { stop: [], steer: [], sessions: [] };
|
||||
const harness = {
|
||||
ensureRunning: async () => true,
|
||||
workspaceSession: (id) => {
|
||||
calls.sessions.push(id);
|
||||
return {
|
||||
async sessionExists() {
|
||||
return true;
|
||||
},
|
||||
async ask(text, options) {
|
||||
return harness.ask(id, text, options);
|
||||
},
|
||||
async stopActiveTurn(control, options) {
|
||||
calls.stop.push({ control, options });
|
||||
return stopped;
|
||||
},
|
||||
async steerActiveTurn(text, control, options) {
|
||||
calls.steer.push({ text, control, options });
|
||||
return steered;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
harness: {
|
||||
ensureRunning: async () => true,
|
||||
workspaceSession: (id) => {
|
||||
calls.sessions.push(id);
|
||||
return {
|
||||
async stopActiveTurn(control, options) {
|
||||
calls.stop.push({ control, options });
|
||||
return stopped;
|
||||
},
|
||||
async steerActiveTurn(text, control, options) {
|
||||
calls.steer.push({ text, control, options });
|
||||
return steered;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
harness,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -3491,6 +3779,150 @@ function steerDropdownEvent(messageId, option, operatorOpenId) {
|
|||
};
|
||||
}
|
||||
|
||||
test('pending question blocks card steer and card stop cancels the question', async () => {
|
||||
const fixture = stateFixture([['p2p:ou_owner', 'session-active']]);
|
||||
const sent = [];
|
||||
const questionReady = deferred();
|
||||
const cancelled = deferred();
|
||||
const { calls, harness } = activeTurnHarness();
|
||||
harness.ask = async (sessionId, _text, options) => {
|
||||
await options.onInteraction({
|
||||
kind: 'question',
|
||||
interactionId: 'card-question',
|
||||
rpcId: 'card-question',
|
||||
sessionId,
|
||||
payload: {
|
||||
type: 'question/requested',
|
||||
sessionId,
|
||||
questions: [{ id: 'answer', question: 'Please answer before continuing' }],
|
||||
},
|
||||
respond: async (result) => {
|
||||
cancelled.resolve(result);
|
||||
return { accepted: true };
|
||||
},
|
||||
});
|
||||
questionReady.resolve();
|
||||
await cancelled.promise;
|
||||
const error = new Error('turn stopped');
|
||||
error.code = 'turn-stopped';
|
||||
throw error;
|
||||
};
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: cardClient(async (outgoing) => sent.push(outgoing)),
|
||||
channel: {},
|
||||
harness,
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||||
});
|
||||
|
||||
await bridge.accept(event('question-menu-open', '/m', { senderOpenId: 'ou_owner' }));
|
||||
await bridge.waitForIdle();
|
||||
const turn = bridge.accept(event('question-task-start', 'start a question', {
|
||||
senderOpenId: 'ou_owner',
|
||||
}));
|
||||
await questionReady.promise;
|
||||
|
||||
await bridge.accept(event('question-new-blocked', '/new', { senderOpenId: 'ou_owner' }));
|
||||
await bridge.onCardAction(cardActionEvent('om_card_1', 'new', 'ou_owner'));
|
||||
assert.equal(
|
||||
fixture.sessions.get('p2p:ou_owner'),
|
||||
'session-active',
|
||||
'text and card new-session actions must not clear a pending interaction',
|
||||
);
|
||||
|
||||
await bridge.onCardAction(steerDropdownEvent('om_card_1', '继续', 'ou_owner'));
|
||||
assert.equal(calls.steer.length, 0, 'a pending question must block card steer');
|
||||
assert.match(JSON.parse(sent.at(-1).content).text, /等待你的回答或审批/);
|
||||
|
||||
await bridge.onCardAction(cardActionEvent('om_card_1', 'stop', 'ou_owner'));
|
||||
const cancellation = await cancelled.promise;
|
||||
await turn;
|
||||
|
||||
assert.equal(calls.stop.length, 1);
|
||||
assert.equal(calls.stop[0].control.key, 'p2p:ou_owner');
|
||||
assert.deepEqual(cancellation, {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'cancelled',
|
||||
message: 'The Feishu interaction ended before the user answered.',
|
||||
details: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('pending approval blocks card steer and card stop rejects the approval', async () => {
|
||||
const fixture = stateFixture([['p2p:ou_owner', 'session-active']]);
|
||||
const sent = [];
|
||||
const approvalReady = deferred();
|
||||
const decided = deferred();
|
||||
const { calls, harness } = activeTurnHarness();
|
||||
harness.ask = async (sessionId, _text, options) => {
|
||||
await options.onInteraction({
|
||||
kind: 'approval',
|
||||
interactionId: 'card-approval',
|
||||
rpcId: 'card-approval-rpc',
|
||||
sessionId,
|
||||
payload: {
|
||||
type: 'approval/requested',
|
||||
sessionId,
|
||||
approvalId: 'card-approval',
|
||||
toolName: 'bash',
|
||||
callId: 'card-approval-call',
|
||||
reason: 'Run a protected command',
|
||||
},
|
||||
toolCall: {
|
||||
callId: 'card-approval-call',
|
||||
name: 'bash',
|
||||
arguments: JSON.stringify({ command: 'true' }),
|
||||
},
|
||||
respond: async (result) => {
|
||||
decided.resolve(result);
|
||||
return { accepted: true };
|
||||
},
|
||||
});
|
||||
approvalReady.resolve();
|
||||
await decided.promise;
|
||||
const error = new Error('turn stopped');
|
||||
error.code = 'turn-stopped';
|
||||
throw error;
|
||||
};
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: cardClient(async (outgoing) => sent.push(outgoing)),
|
||||
channel: {},
|
||||
harness,
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||||
});
|
||||
|
||||
await bridge.accept(event('approval-menu-open', '/m', { senderOpenId: 'ou_owner' }));
|
||||
await bridge.waitForIdle();
|
||||
const turn = bridge.accept(event('approval-card-task', 'start an approval', {
|
||||
senderOpenId: 'ou_owner',
|
||||
}));
|
||||
await approvalReady.promise;
|
||||
|
||||
await bridge.onCardAction(steerDropdownEvent('om_card_1', '继续', 'ou_owner'));
|
||||
assert.equal(calls.steer.length, 0, 'a pending approval must block card steer');
|
||||
assert.match(JSON.parse(sent.at(-1).content).text, /等待你的回答或审批/);
|
||||
|
||||
await bridge.onCardAction(cardActionEvent('om_card_1', 'stop', 'ou_owner'));
|
||||
const decision = await decided.promise;
|
||||
await turn;
|
||||
|
||||
assert.equal(calls.stop.length, 1);
|
||||
assert.equal(calls.stop[0].control.key, 'p2p:ou_owner');
|
||||
assert.deepEqual(decision, {
|
||||
ok: true,
|
||||
value: {
|
||||
sessionId: 'session-active',
|
||||
approvalId: 'card-approval',
|
||||
outcome: 'rejected',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('menu stop button stops the bound active turn without touching the model', async () => {
|
||||
const fixture = stateFixture([['p2p:ou_owner', 'session-active']]);
|
||||
const sent = [];
|
||||
|
|
|
|||
|
|
@ -2,11 +2,20 @@ import assert from 'node:assert/strict';
|
|||
import test from 'node:test';
|
||||
import {
|
||||
cardActionProbeCard,
|
||||
completionCard,
|
||||
customSteerCard,
|
||||
helpCard,
|
||||
menuCard,
|
||||
menuHelpText,
|
||||
modelCard,
|
||||
presetCard,
|
||||
sessionListCard,
|
||||
statusCard,
|
||||
steerCard,
|
||||
watchListCard,
|
||||
workspaceListCard,
|
||||
} from '../../../src/channels/feishu/feishu-cards.mjs';
|
||||
import { setImHostLanguage } from '../../../src/channels/shared/i18n.mjs';
|
||||
|
||||
function buttons(value, result = []) {
|
||||
if (Array.isArray(value)) {
|
||||
|
|
@ -127,3 +136,98 @@ test('model card dropdown highlights the current model via initial_index', () =>
|
|||
// though the id itself contains a `/`.
|
||||
assert.equal(pick.initial_index, 2);
|
||||
});
|
||||
|
||||
test('a single preset remains selectable alongside follow-default', () => {
|
||||
const card = JSON.parse(presetCard({
|
||||
defaultId: 'preset-one',
|
||||
_currentId: null,
|
||||
items: [{ id: 'preset-one', label: 'Preset One' }],
|
||||
}));
|
||||
const pick = selects(card).find((select) => select.name === 'preset_pick');
|
||||
assert.deepEqual(pick?.options.map((option) => option.value), ['preset-one']);
|
||||
const reset = buttons(card).find((button) => (
|
||||
button.behaviors?.[0]?.value?.action === 'preset_default'
|
||||
));
|
||||
assert.ok(reset, 'follow-default must remain available beside the sole preset');
|
||||
});
|
||||
|
||||
test('menu without sessions does not emit an empty session dropdown', () => {
|
||||
const card = JSON.parse(menuCard({ sessions: [] }));
|
||||
const pick = selects(card).find((select) => select.name === 'session_pick');
|
||||
assert.equal(pick, undefined, 'Feishu must not receive select_static with options: []');
|
||||
});
|
||||
|
||||
test('quick steer dropdowns start without a preselected command', () => {
|
||||
const menuPick = selects(JSON.parse(menuCard()))
|
||||
.find((select) => select.name === 'steer_pick');
|
||||
const cardPick = selects(JSON.parse(steerCard({ hasSession: true })))
|
||||
.find((select) => select.name === 'steer_quick');
|
||||
assert.equal(menuPick?.initial_index ?? 0, 0);
|
||||
assert.equal(cardPick?.initial_index ?? 0, 0);
|
||||
});
|
||||
|
||||
test('reachable Feishu cards contain no Chinese literals in English mode', () => {
|
||||
const presetCatalog = {
|
||||
defaultId: 'preset-one',
|
||||
_currentId: 'preset-two',
|
||||
items: [
|
||||
{ id: 'preset-one', label: 'Preset One' },
|
||||
{ id: 'preset-two', label: 'Preset Two' },
|
||||
],
|
||||
};
|
||||
const modelCatalog = {
|
||||
groups: [{
|
||||
id: 'provider',
|
||||
name: 'Provider',
|
||||
models: [
|
||||
{ id: 'model-one', name: 'Model One' },
|
||||
{ id: 'model-two', name: 'Model Two' },
|
||||
],
|
||||
}],
|
||||
current: { provider: 'provider', model: 'model-two' },
|
||||
};
|
||||
const sessions = [{ sessionId: 'session-one', title: 'Session One' }];
|
||||
const rendered = [];
|
||||
|
||||
setImHostLanguage('en');
|
||||
try {
|
||||
rendered.push(
|
||||
menuCard({
|
||||
workspaces: ['/work'],
|
||||
currentWorkspace: '/work',
|
||||
currentSession: { id: 'session-one', title: 'Session One' },
|
||||
sessions: [{ id: 'session-one', title: 'Session One' }],
|
||||
archiveVisible: false,
|
||||
presetCatalog,
|
||||
modelCatalog,
|
||||
}),
|
||||
presetCard(presetCatalog),
|
||||
modelCard(modelCatalog),
|
||||
statusCard({
|
||||
connected: true,
|
||||
workspace: '/work',
|
||||
preset: 'Preset Two',
|
||||
model: 'provider/model-two',
|
||||
sessionCount: 1,
|
||||
}),
|
||||
helpCard(['Additional help']),
|
||||
sessionListCard('/work', sessions, 0, 1),
|
||||
workspaceListCard(['/work'], '/work'),
|
||||
watchListCard(
|
||||
[{ sessionId: 'session-one', title: 'Session One' }],
|
||||
[{ sessionId: 'session-two', title: 'Session Two' }],
|
||||
),
|
||||
completionCard('session-one', 'Session One', 'completed'),
|
||||
steerCard({ hasSession: true }),
|
||||
customSteerCard(),
|
||||
cardActionProbeCard('0123456789abcdef0123456789abcdef'),
|
||||
menuHelpText(),
|
||||
);
|
||||
} finally {
|
||||
setImHostLanguage('zh');
|
||||
}
|
||||
|
||||
for (const output of rendered) {
|
||||
assert.doesNotMatch(output, /[\u3400-\u9fff]/u);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue