Fix structured attachment rendering (#69)
* review: harden readonly git checks and lint verification * test: satisfy readonly reviewer sandbox typing * test: fix readonly reviewer sandbox assertions * test: remove impossible readonly sandbox guards * test: relax readonly sandbox assertions * test: cast readonly sandbox expectation shape * test: cast readonly sandbox expectation through unknown * Phase 0 — STEP_DONE/TASK_DONE split + owner-follow-up integration smoke * Add STEP_DONE guards, verdict storage, and stale delivery suppression * style: format paired stepdone telemetry files * Route STEP_DONE through reviewer * Add structured Discord attachments * style: format structured attachment test * Fix room thread bot output parity * Remove duplicate work item attachment migration from owner branch * Remove legacy dashboard rooms renderer * Extract dashboard parsed body renderer * Extract dashboard redaction helpers * Extract dashboard RoomCardV2 component * Include RoomCardV2 test in vitest suite * Extract dashboard RoomBoardV2 component * Extract dashboard EmptyState component * Extract dashboard InboxPanel component * Split dashboard InboxPanel card renderer * Extract dashboard TaskPanel component * Extract dashboard UsagePanel component * Fix dashboard room thread chunk rendering * Extract dashboard ServicePanel component * Render dashboard live progress markdown * Extract dashboard SettingsPanel component * Fix structured attachment rendering
This commit is contained in:
171
src/channels/discord-structured-output.test.ts
Normal file
171
src/channels/discord-structured-output.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./registry.js', () => ({
|
||||
registerChannel: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../env.js', () => ({
|
||||
readEnvFile: vi.fn(() => ({})),
|
||||
getEnv: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../config.js', () => ({
|
||||
ASSISTANT_NAME: 'Andy',
|
||||
TRIGGER_PATTERN: /^@Andy\b/i,
|
||||
DATA_DIR: '/tmp/ejclaw-test-data',
|
||||
CACHE_DIR: '/tmp/ejclaw-test-cache',
|
||||
}));
|
||||
|
||||
vi.mock('../logger.js', () => ({
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../service-routing.js', () => ({
|
||||
hasReviewerLease: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
type Handler = (...args: any[]) => any;
|
||||
|
||||
const clientRef = vi.hoisted(() => ({ current: null as any }));
|
||||
|
||||
vi.mock('discord.js', () => {
|
||||
class MockClient {
|
||||
eventHandlers = new Map<string, Handler[]>();
|
||||
user: any = { id: '999888777', tag: 'Andy#1234' };
|
||||
private _ready = false;
|
||||
|
||||
constructor(_opts: any) {
|
||||
clientRef.current = this;
|
||||
}
|
||||
|
||||
on(event: string, handler: Handler) {
|
||||
this.eventHandlers.set(event, [
|
||||
...(this.eventHandlers.get(event) ?? []),
|
||||
handler,
|
||||
]);
|
||||
return this;
|
||||
}
|
||||
|
||||
once(event: string, handler: Handler) {
|
||||
return this.on(event, handler);
|
||||
}
|
||||
|
||||
async login(_token: string) {
|
||||
this._ready = true;
|
||||
for (const handler of this.eventHandlers.get('ready') ?? []) {
|
||||
handler({ user: this.user });
|
||||
}
|
||||
}
|
||||
|
||||
isReady() {
|
||||
return this._ready;
|
||||
}
|
||||
|
||||
channels = {
|
||||
fetch: vi.fn().mockResolvedValue({
|
||||
send: vi.fn().mockResolvedValue(undefined),
|
||||
sendTyping: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
Client: MockClient,
|
||||
Events: {
|
||||
MessageCreate: 'messageCreate',
|
||||
ClientReady: 'ready',
|
||||
Error: 'error',
|
||||
},
|
||||
GatewayIntentBits: {
|
||||
Guilds: 1,
|
||||
GuildMessages: 2,
|
||||
MessageContent: 4,
|
||||
DirectMessages: 8,
|
||||
},
|
||||
MessageFlags: { SuppressEmbeds: 1 << 2, IsVoiceMessage: 1 << 13 },
|
||||
TextChannel: class TextChannel {},
|
||||
};
|
||||
});
|
||||
|
||||
import { DiscordChannel, type DiscordChannelOpts } from './discord.js';
|
||||
|
||||
const ONE_PIXEL_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
|
||||
const tempFiles: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
for (const file of tempFiles.splice(0)) {
|
||||
fs.rmSync(file, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createTestOpts(): DiscordChannelOpts {
|
||||
return {
|
||||
onMessage: vi.fn(),
|
||||
onChatMetadata: vi.fn(),
|
||||
roomBindings: vi.fn(() => ({})),
|
||||
};
|
||||
}
|
||||
|
||||
describe('DiscordChannel structured output', () => {
|
||||
it('normalizes raw EJClaw JSON and sends direct temp images as files', async () => {
|
||||
const channel = new DiscordChannel('test-token', createTestOpts());
|
||||
await channel.connect();
|
||||
const filePath = path.join(
|
||||
os.tmpdir(),
|
||||
`bar-chart-label-fit-playwright-${Date.now()}.png`,
|
||||
);
|
||||
fs.writeFileSync(filePath, ONE_PIXEL_PNG);
|
||||
tempFiles.push(filePath);
|
||||
const mockChannel = {
|
||||
send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }),
|
||||
sendTyping: vi.fn(),
|
||||
};
|
||||
clientRef.current.channels.fetch.mockResolvedValue(mockChannel);
|
||||
|
||||
await channel.sendMessage(
|
||||
'dc:1234567890123456',
|
||||
JSON.stringify({
|
||||
ejclaw: {
|
||||
visibility: 'public',
|
||||
text: '라벨 좌측 클리핑 회귀 수정했습니다.',
|
||||
verdict: 'done',
|
||||
attachments: [
|
||||
{
|
||||
path: filePath,
|
||||
name: 'bar-chart-label-fit-playwright.png',
|
||||
mime: 'image/png',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockChannel.send).toHaveBeenCalledWith({
|
||||
content: '라벨 좌측 클리핑 회귀 수정했습니다.',
|
||||
files: [
|
||||
{
|
||||
attachment: fs.realpathSync(filePath),
|
||||
name: 'bar-chart-label-fit-playwright.png',
|
||||
},
|
||||
],
|
||||
flags: 1 << 2,
|
||||
});
|
||||
expect(JSON.stringify(mockChannel.send.mock.calls)).not.toContain(
|
||||
'"ejclaw"',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user