kimi-code/packages/oauth/test/managed-feedback.test.ts
7Sageer e736349a7c
feat(feedback): support attaching logs and codebase (#1120)
* feat(feedback): support attaching logs and codebase

Add an attachment picker to /feedback (none / logs / logs + codebase).
Codebase uploads scan the working directory with sensitive files excluded
and are sent through a new multipart upload API on the oauth/node-sdk layers.

* fix(feedback): fall back to logs when codebase scan fails

* tiny fix

* fix(feedback): make diagnostic uploads partial-safe

* refactor(feedback): reuse harness session export and normalize upload url types

* docs(slash-commands): note optional feedback attachments

* refactor(feedback): reorganize feedback upload modules

Move the attachment orchestration out of tui/commands/info.ts into a
dedicated feedback/feedback-attachments.ts, and split the former
codebase-upload/attach.ts into a generic multipart uploader
(feedback/upload.ts) and an archive lifecycle module
(feedback/archive.ts). Both session and codebase archives now flow
through a single upload lifecycle, which also removes the temp-dir
leak that occurred when codebase packaging failed.

Rename FeedbackCodebaseArchive to FeedbackArchive and the
codebase-upload/ directory to codebase/ so module boundaries match
their actual responsibilities (scan + package only).
2026-06-26 16:15:08 +08:00

208 lines
6 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import {
fetchSubmitFeedback,
kimiCodeFeedbackUrl,
type SubmitFeedbackBody,
} from '../src/managed-feedback';
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
const SAMPLE_BODY: SubmitFeedbackBody = {
session_id: 'sess-123',
content: 'great tool',
version: 'kimi-code-0.1.1',
os: 'Darwin 25.3.0',
model: 'kimi-code/kimi-for-coding',
contact: 'test@example.com',
info: { tool: 'kimi-code-cli', env: 'test' },
};
describe('kimiCodeFeedbackUrl', () => {
it('appends /feedback to the default base URL', () => {
expect(kimiCodeFeedbackUrl()).toBe('https://api.kimi.com/coding/v1/feedback');
});
it('honours KIMI_CODE_BASE_URL and trims trailing slashes', () => {
vi.stubEnv('KIMI_CODE_BASE_URL', 'https://example.test/v9///');
expect(kimiCodeFeedbackUrl()).toBe('https://example.test/v9/feedback');
});
});
describe('fetchSubmitFeedback', () => {
it('POSTs JSON body with bearer auth and returns feedback_id on 200', async () => {
const fetchMock = vi.fn(async () =>
new Response(JSON.stringify({ feedback_id: 3 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
const result = await fetchSubmitFeedback(
'https://api.example/feedback',
'access-token',
SAMPLE_BODY,
);
expect(result).toEqual({ kind: 'ok', feedbackId: 3 });
const calls = fetchMock.mock.calls as unknown as [string, RequestInit?][];
const [calledUrl, init] = calls[0]!;
expect(calledUrl).toBe('https://api.example/feedback');
expect(init?.method).toBe('POST');
const headers = new Headers((init?.headers ?? {}) as Record<string, string>);
expect(headers.get('authorization')).toBe('Bearer access-token');
expect(headers.get('content-type')).toBe('application/json');
expect(headers.get('accept')).toBe('application/json');
expect(JSON.parse(init?.body as string)).toEqual(SAMPLE_BODY);
});
it('returns an error when the server omits feedback_id', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () =>
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
),
);
const result = await fetchSubmitFeedback('https://api.example/feedback', 'access-token', SAMPLE_BODY);
expect(result).toEqual({
kind: 'error',
message: 'Failed to submit feedback: missing feedback_id.',
});
});
it('preserves the kimi-code- version prefix in the request body', async () => {
const fetchMock = vi.fn(async () =>
new Response(JSON.stringify({ feedback_id: 3 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
await fetchSubmitFeedback('https://api.example/feedback', 'tok', SAMPLE_BODY);
const calls = fetchMock.mock.calls as unknown as [string, RequestInit?][];
const sent = JSON.parse(calls[0]?.[1]?.body as string) as Record<string, unknown>;
expect(sent['version']).toBe('kimi-code-0.1.1');
});
it('returns an error with status when the server responds 401', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('', { status: 401 })),
);
const result = await fetchSubmitFeedback(
'https://api.example/feedback',
'access-token',
SAMPLE_BODY,
);
expect(result.kind).toBe('error');
if (result.kind !== 'error') return;
expect(result.status).toBe(401);
expect(result.message).toMatch(/401/);
});
it('surfaces API error messages from failed submissions', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response(JSON.stringify({ error: { message: 'feedback rejected' } }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
}),
),
);
const result = await fetchSubmitFeedback(
'https://api.example/feedback',
'access-token',
SAMPLE_BODY,
);
expect(result.kind).toBe('error');
if (result.kind !== 'error') return;
expect(result.status).toBe(400);
expect(result.message).toBe('feedback rejected');
});
it('returns an error with status when the server responds 500', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('', { status: 500 })),
);
const result = await fetchSubmitFeedback(
'https://api.example/feedback',
'access-token',
SAMPLE_BODY,
);
expect(result.kind).toBe('error');
if (result.kind !== 'error') return;
expect(result.status).toBe(500);
expect(result.message).toBe('Failed to submit feedback: HTTP 500');
});
it('returns a timeout error when the request aborts', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
(_url: string, init?: RequestInit) =>
new Promise<Response>((_, reject) => {
init?.signal?.addEventListener('abort', () => {
const err = new Error('aborted');
err.name = 'AbortError';
reject(err);
});
}),
),
);
const result = await fetchSubmitFeedback(
'https://api.example/feedback',
'access-token',
SAMPLE_BODY,
{ timeoutMs: 5 },
);
expect(result.kind).toBe('error');
if (result.kind !== 'error') return;
expect(result.status).toBeUndefined();
expect(result.message).toMatch(/timed out/);
});
it('returns a generic error message on network failure', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new TypeError('network down');
}),
);
const result = await fetchSubmitFeedback(
'https://api.example/feedback',
'access-token',
SAMPLE_BODY,
);
expect(result.kind).toBe('error');
if (result.kind !== 'error') return;
expect(result.status).toBeUndefined();
expect(result.message).toMatch(/network down/);
});
});