Fix critical bugs in the CLI transport layer that were masked by idealized unit test mocks. All fixes verified against live binaries. Server (gitea/client.js): - C1: Remove --paginate flag (tea rejects it with exit 1) - C2: Pass auth via -H 'Authorization: token' header instead of GITEA_SERVER_TOKEN env (tea ignores that env var) - C3: Parse --include output from stderr (tea writes headers to stderr, not stdout) - H2: Parse Link header for hasMore/pagination Server (gitlab/client.js): - C4: Remove /api/v4 prefix (glab adds it automatically; double prefix caused every call to 404) - H2: Parse Link header for hasMore/pagination Tests (both client.test.js): - H1: Rewrite mocks to match real CLI behavior: headers on stderr for tea, no --paginate, auth via -H header, Link header parsing - Add C3, C1, C4 specific regression tests UI (GiteaSettings, GitLabSettings): - U4: Replace return null loading state with animated skeleton (prevents blank flash) - U2: Surface actual error message in connect failure toast - U5: Add CLI transport hint below connect form - U6 (GitLab): Add transport hint for unconnected state UI (GiteaIssuePickerDialog, GitLabIssuePickerDialog): - U3: Add retry button when error state is displayed Live smoke tests passed: - tea api -H 'Authorization: token WRONG' /user → 401 - tea api /repos/Vibing/openchamber/issues?state=open&limit=2 → 200 - glab api user with GITLAB_TOKEN=dummy → 401 (not 404)
374 lines
14 KiB
JavaScript
374 lines
14 KiB
JavaScript
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import { EventEmitter } from 'events';
|
|
import { afterAll, afterEach, describe, expect, test, vi } from 'vitest';
|
|
|
|
// Isolate auth storage so getGiteaClientOrNull never reads a real account.
|
|
const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-gitea-client-'));
|
|
process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR;
|
|
|
|
afterAll(() => {
|
|
fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true });
|
|
});
|
|
|
|
// Mock child_process.spawn to simulate real `tea api` output.
|
|
// Real tea behavior:
|
|
// - `--include` writes status+headers to stderr, body to stdout
|
|
// - `--paginate` does NOT exist (tea rejects it)
|
|
// - Auth via `-H "Authorization: token <token>"`, NOT via GITEA_SERVER_TOKEN env
|
|
const originalSpawn = (await import('child_process')).spawn;
|
|
let spawnMock = null;
|
|
|
|
vi.mock('child_process', () => ({
|
|
spawn: (...args) => (spawnMock ? spawnMock(...args) : originalSpawn(...args)),
|
|
}));
|
|
|
|
const {
|
|
createGiteaClient,
|
|
getGiteaClientOrNull,
|
|
isGiteaRateLimited,
|
|
noteGiteaRateLimit,
|
|
} = await import('./client.js');
|
|
|
|
/**
|
|
* Build real tea --include output: status+headers on stderr, body on stdout.
|
|
*/
|
|
const cliOutput = (data, { status = 200, headers = {} } = {}) => {
|
|
const stderrLines = [`HTTP/2.0 ${status} OK`];
|
|
for (const [k, v] of Object.entries(headers)) {
|
|
stderrLines.push(`${k}: ${v}`);
|
|
}
|
|
stderrLines.push('');
|
|
return {
|
|
stdout: typeof data === 'string' ? data : JSON.stringify(data),
|
|
stderr: stderrLines.join('\n'),
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Create a vi.fn() mock spawn that emits stderr (headers) and stdout (body)
|
|
* separately, matching real tea --include behavior.
|
|
*/
|
|
const mockSpawn = (output, { exitCode = 0 } = {}) => {
|
|
const { stdout, stderr } = typeof output === 'object' && 'stdout' in output
|
|
? output
|
|
: cliOutput(output);
|
|
return vi.fn((...args) => {
|
|
const child = new EventEmitter();
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
child.kill = vi.fn();
|
|
queueMicrotask(() => {
|
|
child.stdout.emit('data', stdout);
|
|
child.stderr.emit('data', stderr);
|
|
child.emit('close', exitCode);
|
|
});
|
|
return child;
|
|
});
|
|
};
|
|
|
|
afterEach(() => {
|
|
spawnMock = null;
|
|
});
|
|
|
|
describe('createGiteaClient request basics', () => {
|
|
test('spawns tea api --include and passes auth via -H header (not env)', async () => {
|
|
spawnMock = mockSpawn(cliOutput({ id: 42, login: 'alice' }));
|
|
|
|
const client = createGiteaClient({ token: 'gitea-token', baseUrl: 'https://gitea.example.com' });
|
|
const result = await client.user();
|
|
|
|
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
const [bin, args, opts] = spawnMock.mock.calls[0];
|
|
expect(bin).toBe('/home/user/.local/bin/tea');
|
|
expect(args).toContain('--include');
|
|
expect(args).toContain('-H');
|
|
expect(args).toContain('Authorization: token gitea-token');
|
|
expect(args).not.toContain('--paginate');
|
|
// Auth is via header, NOT via GITEA_SERVER_TOKEN env (C2 fix).
|
|
expect(opts.env.GITEA_SERVER_TOKEN).toBeUndefined();
|
|
expect(result).toMatchObject({ status: 200, data: { id: 42, login: 'alice' } });
|
|
expect(result.error).toBeUndefined();
|
|
});
|
|
|
|
test('joins a custom base URL with a path without duplicating /api/v1', async () => {
|
|
spawnMock = mockSpawn(cliOutput([]));
|
|
|
|
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com/gitea/' });
|
|
await client.issues('owner', 'repo', { state: 'open' });
|
|
|
|
const [, args] = spawnMock.mock.calls[0];
|
|
expect(args).toContain('/api/v1/repos/owner/repo/issues?state=open');
|
|
});
|
|
|
|
test('serializes query params and omits empty ones', async () => {
|
|
spawnMock = mockSpawn(cliOutput([]));
|
|
|
|
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
|
await client.pullRequests('owner', 'repo', { state: 'open', limit: 50, page: 2, q: '', sort: null });
|
|
|
|
const [, args] = spawnMock.mock.calls[0];
|
|
const endpoint = args[args.length - 1];
|
|
expect(endpoint).toContain('state=open');
|
|
expect(endpoint).toContain('limit=50');
|
|
expect(endpoint).toContain('page=2');
|
|
expect(endpoint).not.toContain('q');
|
|
expect(endpoint).not.toContain('sort');
|
|
});
|
|
|
|
test('POST requests pass the method flag and a JSON body', async () => {
|
|
spawnMock = mockSpawn(cliOutput({ ok: true }, { status: 201 }));
|
|
|
|
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
|
await client.request('/some/action', { method: 'POST', body: { hello: 'world' } });
|
|
|
|
const [, args] = spawnMock.mock.calls[0];
|
|
expect(args).toContain('-X');
|
|
expect(args).toContain('POST');
|
|
expect(args).toContain('-d');
|
|
expect(args).toContain(JSON.stringify({ hello: 'world' }));
|
|
});
|
|
|
|
test('surfaces error statuses without throwing', async () => {
|
|
spawnMock = mockSpawn(cliOutput({ message: 'nope' }, { status: 401 }));
|
|
|
|
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
|
const result = await client.user();
|
|
expect(result.status).toBe(401);
|
|
expect(result.data).toEqual({ message: 'nope' });
|
|
});
|
|
|
|
test('raw requests pass Accept: text/plain header', async () => {
|
|
spawnMock = mockSpawn(cliOutput('diff --git a/src/a.ts b/src/a.ts\n'));
|
|
|
|
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
|
const result = await client.pullRequestDiff('owner', 'repo', 5);
|
|
|
|
const [, args] = spawnMock.mock.calls[0];
|
|
expect(args).toContain('--header');
|
|
expect(args).toContain('Accept: text/plain');
|
|
expect(args).toContain('/api/v1/repos/owner/repo/pulls/5.diff');
|
|
expect(result.status).toBe(200);
|
|
expect(result.data).toBe('diff --git a/src/a.ts b/src/a.ts');
|
|
});
|
|
|
|
test('returns 500 on CLI process error', async () => {
|
|
spawnMock = (...args) => {
|
|
const child = new EventEmitter();
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
child.kill = vi.fn();
|
|
queueMicrotask(() => {
|
|
child.emit('error', new Error('ENOENT'));
|
|
});
|
|
return child;
|
|
};
|
|
|
|
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
|
const result = await client.user();
|
|
expect(result.status).toBe(500);
|
|
expect(result.error).toContain('ENOENT');
|
|
});
|
|
|
|
test('returns 500 on non-zero exit with no stdout and no stderr status line', async () => {
|
|
spawnMock = mockSpawn({ stdout: '', stderr: 'not logged in' }, { exitCode: 1 });
|
|
|
|
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
|
const result = await client.user();
|
|
expect(result.status).toBe(500);
|
|
expect(result.error).toBe('not logged in');
|
|
});
|
|
|
|
test('parses status code from stderr --include output (C3 fix)', async () => {
|
|
// Simulate tea's real output: 403 on stderr, error body on stdout.
|
|
spawnMock = mockSpawn(cliOutput({ message: 'forbidden' }, { status: 403 }));
|
|
|
|
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
|
const result = await client.user();
|
|
expect(result.status).toBe(403);
|
|
expect(result.data).toEqual({ message: 'forbidden' });
|
|
});
|
|
});
|
|
|
|
describe('pagination', () => {
|
|
test('returns hasMore=true when Link header has rel="next"', async () => {
|
|
spawnMock = mockSpawn(
|
|
cliOutput([{ id: 1 }], {
|
|
headers: {
|
|
'Link': '<https://gitea.example.com/api/v1/repos/o/r/issues?limit=1&page=2>; rel="next"',
|
|
},
|
|
}),
|
|
);
|
|
|
|
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
|
const result = await client.issues('o', 'r', { limit: 1, page: 1 });
|
|
expect(result.hasMore).toBe(true);
|
|
expect(result.page).toBe(1);
|
|
expect(Array.isArray(result.data)).toBe(true);
|
|
});
|
|
|
|
test('returns hasMore=false when no Link header', async () => {
|
|
spawnMock = mockSpawn(cliOutput([{ id: 1 }]));
|
|
|
|
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
|
const result = await client.issues('o', 'r', { limit: 50 });
|
|
expect(result.hasMore).toBe(false);
|
|
expect(result.page).toBe(1);
|
|
});
|
|
|
|
test('page is null for non-array responses', async () => {
|
|
spawnMock = mockSpawn(cliOutput({ id: 42 }));
|
|
|
|
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
|
const result = await client.user();
|
|
expect(result.page).toBeNull();
|
|
});
|
|
|
|
test('does NOT pass --paginate flag to tea (C1 fix)', async () => {
|
|
spawnMock = mockSpawn(cliOutput([]));
|
|
|
|
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
|
await client.issues('o', 'r', { state: 'open', page: 2 });
|
|
|
|
const [, args] = spawnMock.mock.calls[0];
|
|
expect(args).not.toContain('--paginate');
|
|
expect(args).not.toContain('-paginate');
|
|
});
|
|
});
|
|
|
|
describe('pull request write methods', () => {
|
|
test('createPullRequest POSTs to the pulls endpoint', async () => {
|
|
spawnMock = mockSpawn(cliOutput({ number: 5, title: 'New PR' }, { status: 201 }));
|
|
|
|
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
|
const result = await client.createPullRequest('owner', 'repo', {
|
|
title: 'New PR',
|
|
head: 'feat/x',
|
|
base: 'main',
|
|
});
|
|
|
|
const [, args] = spawnMock.mock.calls[0];
|
|
expect(args).toContain('-X');
|
|
expect(args).toContain('POST');
|
|
expect(args).toContain(JSON.stringify({ title: 'New PR', head: 'feat/x', base: 'main' }));
|
|
expect(args.some(a => typeof a === 'string' && a.includes('/repos/owner/repo/pulls'))).toBe(true);
|
|
expect(result.status).toBe(201);
|
|
expect(result.data).toEqual({ number: 5, title: 'New PR' });
|
|
});
|
|
|
|
test('updatePullRequest PATCHes to the pull request endpoint', async () => {
|
|
spawnMock = mockSpawn(cliOutput({ number: 5, title: 'Updated' }));
|
|
|
|
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
|
await client.updatePullRequest('owner', 'repo', 5, { title: 'Updated', body: 'Body text' });
|
|
|
|
const [, args] = spawnMock.mock.calls[0];
|
|
expect(args).toContain('-X');
|
|
expect(args).toContain('PATCH');
|
|
expect(args).toContain(JSON.stringify({ title: 'Updated', body: 'Body text' }));
|
|
});
|
|
|
|
test('mergePullRequest POSTs the merge style to the merge endpoint', async () => {
|
|
spawnMock = mockSpawn(cliOutput({ merged: true }));
|
|
|
|
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
|
await client.mergePullRequest('owner', 'repo', 5, { Do: 'squash' });
|
|
|
|
const [, args] = spawnMock.mock.calls[0];
|
|
expect(args).toContain('-X');
|
|
expect(args).toContain('POST');
|
|
expect(args).toContain(JSON.stringify({ Do: 'squash' }));
|
|
expect(args.some(a => typeof a === 'string' && a.includes('/pulls/5/merge'))).toBe(true);
|
|
});
|
|
|
|
test('write methods surface error statuses without throwing', async () => {
|
|
spawnMock = mockSpawn(cliOutput({ message: 'Conflict' }, { status: 409 }));
|
|
|
|
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
|
const result = await client.mergePullRequest('owner', 'repo', 5, { Do: 'merge' });
|
|
expect(result.status).toBe(409);
|
|
expect(result.data).toEqual({ message: 'Conflict' });
|
|
});
|
|
});
|
|
|
|
describe('issue, review, and repo write methods', () => {
|
|
test('createIssueComment POSTs a body to the issue comments endpoint', async () => {
|
|
spawnMock = mockSpawn(cliOutput({ id: 5, body: 'hi' }, { status: 201 }));
|
|
|
|
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
|
const result = await client.createIssueComment('owner', 'repo', 7, 'Nice catch');
|
|
|
|
const [, args] = spawnMock.mock.calls[0];
|
|
expect(args).toContain('-X');
|
|
expect(args).toContain('POST');
|
|
expect(args).toContain(JSON.stringify({ body: 'Nice catch' }));
|
|
expect(args.some(a => typeof a === 'string' && a.includes('/issues/7/comments'))).toBe(true);
|
|
expect(result.status).toBe(201);
|
|
});
|
|
|
|
test('updateIssue PATCHes params to the issue endpoint', async () => {
|
|
spawnMock = mockSpawn(cliOutput({ number: 7, title: 'Updated' }));
|
|
|
|
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
|
await client.updateIssue('owner', 'repo', 7, { state: 'closed', labels: ['bug'], milestone: 33 });
|
|
|
|
const [, args] = spawnMock.mock.calls[0];
|
|
expect(args).toContain('-X');
|
|
expect(args).toContain('PATCH');
|
|
expect(args).toContain(JSON.stringify({ state: 'closed', labels: ['bug'], milestone: 33 }));
|
|
});
|
|
|
|
test('createPullReview POSTs event/body to the reviews endpoint', async () => {
|
|
spawnMock = mockSpawn(cliOutput({ id: 101, state: 'APPROVED' }, { status: 201 }));
|
|
|
|
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
|
await client.createPullReview('owner', 'repo', 12, { event: 'APPROVED', body: 'LGTM' });
|
|
|
|
const [, args] = spawnMock.mock.calls[0];
|
|
expect(args).toContain('-X');
|
|
expect(args).toContain('POST');
|
|
expect(args).toContain(JSON.stringify({ event: 'APPROVED', body: 'LGTM' }));
|
|
});
|
|
|
|
test('milestones passes state and limit query params', async () => {
|
|
spawnMock = mockSpawn(cliOutput([{ id: 33, title: 'v1.0' }]));
|
|
|
|
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
|
await client.milestones('owner', 'repo', { state: 'all', limit: 50 });
|
|
|
|
const [, args] = spawnMock.mock.calls[0];
|
|
const endpoint = args[args.length - 1];
|
|
expect(endpoint).toContain('state=all');
|
|
expect(endpoint).toContain('limit=50');
|
|
});
|
|
|
|
test('repoLabels passes limit query param', async () => {
|
|
spawnMock = mockSpawn(cliOutput([{ id: 1, name: 'bug', color: 'd73a4a' }]));
|
|
|
|
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
|
await client.repoLabels('owner', 'repo', { limit: 100 });
|
|
|
|
const [, args] = spawnMock.mock.calls[0];
|
|
const endpoint = args[args.length - 1];
|
|
expect(endpoint).toContain('limit=100');
|
|
});
|
|
});
|
|
|
|
describe('rate limiting', () => {
|
|
// NOTE: these tests run last in this file. The rate-limit cooldown is
|
|
// module-level and has no reset export, so earlier tests must not set one.
|
|
test('isGiteaRateLimited returns false (no-op with CLI transport)', () => {
|
|
expect(isGiteaRateLimited()).toBe(false);
|
|
});
|
|
|
|
test('noteGiteaRateLimit is a no-op', () => {
|
|
noteGiteaRateLimit({ headers: new Headers({ 'retry-after': '120' }) });
|
|
expect(isGiteaRateLimited()).toBe(false);
|
|
});
|
|
|
|
test('getGiteaClientOrNull returns null without stored auth', () => {
|
|
expect(getGiteaClientOrNull()).toBeNull();
|
|
});
|
|
});
|