updated
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { loadConfig } from '../src/config.js';
|
||||
|
||||
const validEnv = {
|
||||
GITEA_URL: 'https://git.example.com/',
|
||||
GITEA_TOKEN: 'tok',
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'oauth',
|
||||
OWNER_LOGIN: 'selimaj-dev',
|
||||
};
|
||||
|
||||
test('loads valid config and strips trailing slash from url', () => {
|
||||
const cfg = loadConfig(validEnv);
|
||||
assert.equal(cfg.giteaUrl, 'https://git.example.com');
|
||||
assert.equal(cfg.port, 3000);
|
||||
assert.equal(cfg.model, 'sonnet');
|
||||
assert.equal(cfg.maxTurns, 25);
|
||||
assert.equal(cfg.webhookSecret, null);
|
||||
});
|
||||
|
||||
test('throws listing all missing required vars', () => {
|
||||
assert.throws(() => loadConfig({}), /GITEA_URL.*GITEA_TOKEN.*CLAUDE_CODE_OAUTH_TOKEN.*OWNER_LOGIN/s);
|
||||
});
|
||||
|
||||
test('throws on invalid GITEA_URL', () => {
|
||||
assert.throws(() => loadConfig({ ...validEnv, GITEA_URL: 'not a url' }), /not a valid URL/);
|
||||
});
|
||||
|
||||
test('throws on non-http(s) protocol', () => {
|
||||
assert.throws(() => loadConfig({ ...validEnv, GITEA_URL: 'ftp://example.com' }), /http or https/);
|
||||
});
|
||||
|
||||
test('throws on invalid PORT', () => {
|
||||
assert.throws(() => loadConfig({ ...validEnv, PORT: 'abc' }), /valid port number/);
|
||||
assert.throws(() => loadConfig({ ...validEnv, PORT: '0' }), /valid port number/);
|
||||
assert.throws(() => loadConfig({ ...validEnv, PORT: '99999' }), /valid port number/);
|
||||
});
|
||||
|
||||
test('applies numeric overrides', () => {
|
||||
const cfg = loadConfig({ ...validEnv, PORT: '8080', JOB_TIMEOUT_MS: '5000', CLAUDE_MAX_TURNS: '10' });
|
||||
assert.equal(cfg.port, 8080);
|
||||
assert.equal(cfg.jobTimeoutMs, 5000);
|
||||
assert.equal(cfg.maxTurns, 10);
|
||||
});
|
||||
|
||||
test('passes through webhook secret and trigger phrase', () => {
|
||||
const cfg = loadConfig({ ...validEnv, WEBHOOK_SECRET: 's3cret', TRIGGER_PHRASE: '@bot' });
|
||||
assert.equal(cfg.webhookSecret, 's3cret');
|
||||
assert.equal(cfg.triggerPhrase, '@bot');
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { classifyEvent } from '../src/events.js';
|
||||
|
||||
const opts = { ownerLogin: 'selimaj-dev', triggerPhrase: '@claude' };
|
||||
|
||||
function issueCommentPayload(overrides = {}) {
|
||||
return {
|
||||
action: 'created',
|
||||
comment: { user: { login: 'selimaj-dev' }, body: '@claude please fix this' },
|
||||
issue: { number: 42, title: 'Bug', body: 'It is broken', pull_request: undefined },
|
||||
repository: { full_name: 'selimaj-dev/repo', default_branch: 'main' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('triggers on an issue comment from the owner mentioning the phrase', () => {
|
||||
const { trigger, reason } = classifyEvent('issue_comment', issueCommentPayload(), opts);
|
||||
assert.equal(reason, null);
|
||||
assert.deepEqual(trigger, {
|
||||
repo: 'selimaj-dev/repo',
|
||||
issueNumber: 42,
|
||||
issueTitle: 'Bug',
|
||||
issueBody: 'It is broken',
|
||||
isPullRequest: false,
|
||||
defaultBranch: 'main',
|
||||
requestText: '@claude please fix this',
|
||||
author: 'selimaj-dev',
|
||||
});
|
||||
});
|
||||
|
||||
test('marks isPullRequest true when issue.pull_request is present', () => {
|
||||
const payload = issueCommentPayload({ issue: { number: 7, title: 'PR', body: '', pull_request: { url: 'x' } } });
|
||||
const { trigger } = classifyEvent('issue_comment', payload, opts);
|
||||
assert.equal(trigger.isPullRequest, true);
|
||||
});
|
||||
|
||||
test('ignores comments not from the configured owner', () => {
|
||||
const payload = issueCommentPayload({ comment: { user: { login: 'someone-else' }, body: '@claude do it' } });
|
||||
const { trigger, reason } = classifyEvent('issue_comment', payload, opts);
|
||||
assert.equal(trigger, null);
|
||||
assert.match(reason, /is not the configured owner/);
|
||||
});
|
||||
|
||||
test('ignores comments without the trigger phrase', () => {
|
||||
const payload = issueCommentPayload({ comment: { user: { login: 'selimaj-dev' }, body: 'just a note' } });
|
||||
const { trigger, reason } = classifyEvent('issue_comment', payload, opts);
|
||||
assert.equal(trigger, null);
|
||||
assert.match(reason, /trigger phrase/);
|
||||
});
|
||||
|
||||
test('ignores non-created issue_comment actions', () => {
|
||||
const payload = issueCommentPayload({ action: 'deleted' });
|
||||
const { trigger, reason } = classifyEvent('issue_comment', payload, opts);
|
||||
assert.equal(trigger, null);
|
||||
assert.match(reason, /not handled/);
|
||||
});
|
||||
|
||||
test('triggers on a new issue whose body mentions the phrase', () => {
|
||||
const payload = {
|
||||
action: 'opened',
|
||||
issue: { number: 1, title: 'New issue', body: 'please @claude help', user: { login: 'selimaj-dev' } },
|
||||
repository: { full_name: 'selimaj-dev/repo', default_branch: 'main' },
|
||||
};
|
||||
const { trigger } = classifyEvent('issues', payload, opts);
|
||||
assert.ok(trigger);
|
||||
assert.equal(trigger.requestText, 'please @claude help');
|
||||
});
|
||||
|
||||
test('ignores unrelated event names', () => {
|
||||
const { trigger, reason } = classifyEvent('push', {}, opts);
|
||||
assert.equal(trigger, null);
|
||||
assert.match(reason, /not handled/);
|
||||
});
|
||||
|
||||
test('ignores null/undefined payload without throwing', () => {
|
||||
assert.doesNotThrow(() => classifyEvent('issue_comment', null, opts));
|
||||
assert.doesNotThrow(() => classifyEvent('issue_comment', undefined, opts));
|
||||
});
|
||||
|
||||
test('ignores payload missing repository/issue info', () => {
|
||||
const payload = issueCommentPayload({ repository: { full_name: undefined } });
|
||||
const { trigger, reason } = classifyEvent('issue_comment', payload, opts);
|
||||
assert.equal(trigger, null);
|
||||
assert.match(reason, /missing repository or issue/);
|
||||
});
|
||||
|
||||
test('defaults default_branch to main when absent', () => {
|
||||
const payload = issueCommentPayload({ repository: { full_name: 'a/b', default_branch: undefined } });
|
||||
const { trigger } = classifyEvent('issue_comment', payload, opts);
|
||||
assert.equal(trigger.defaultBranch, 'main');
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { run, ProcessError, ProcessTimeoutError } from '../src/exec.js';
|
||||
|
||||
test('resolves with stdout on success', async () => {
|
||||
const { stdout } = await run('node', ['-e', 'process.stdout.write("hi")']);
|
||||
assert.equal(stdout, 'hi');
|
||||
});
|
||||
|
||||
test('rejects with ProcessError on non-zero exit, capturing stderr', async () => {
|
||||
await assert.rejects(
|
||||
run('node', ['-e', 'process.stderr.write("bad"); process.exit(2)']),
|
||||
(err) => {
|
||||
assert.ok(err instanceof ProcessError);
|
||||
assert.equal(err.code, 2);
|
||||
assert.equal(err.stderr, 'bad');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects with ProcessTimeoutError when the process exceeds the timeout', async () => {
|
||||
await assert.rejects(
|
||||
run('node', ['-e', 'setTimeout(() => {}, 5000)'], { timeoutMs: 100 }),
|
||||
ProcessTimeoutError,
|
||||
);
|
||||
});
|
||||
|
||||
test('passes cwd and env through to the child process', async () => {
|
||||
const { stdout } = await run('node', ['-e', 'process.stdout.write(process.env.FOO || "")'], {
|
||||
env: { ...process.env, FOO: 'bar' },
|
||||
});
|
||||
assert.equal(stdout, 'bar');
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { GiteaClient, GiteaApiError } from '../src/gitea.js';
|
||||
|
||||
function fakeFetch(handler) {
|
||||
return async (url, opts) => handler(url, opts);
|
||||
}
|
||||
|
||||
function jsonResponse(status, body) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
text: async () => JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
test('getPullRequest returns the PR body on success', async () => {
|
||||
const client = new GiteaClient({
|
||||
baseUrl: 'https://git.example.com',
|
||||
token: 't',
|
||||
fetchImpl: fakeFetch(async (url) => {
|
||||
assert.equal(url, 'https://git.example.com/api/v1/repos/o/r/pulls/5');
|
||||
return jsonResponse(200, { head: { ref: 'feature' } });
|
||||
}),
|
||||
});
|
||||
const pr = await client.getPullRequest('o/r', 5);
|
||||
assert.equal(pr.head.ref, 'feature');
|
||||
});
|
||||
|
||||
test('getPullRequest returns null on 404 (issue, not a PR)', async () => {
|
||||
const client = new GiteaClient({
|
||||
baseUrl: 'https://git.example.com',
|
||||
token: 't',
|
||||
fetchImpl: fakeFetch(async () => jsonResponse(404, { message: 'not found' })),
|
||||
});
|
||||
const pr = await client.getPullRequest('o/r', 5);
|
||||
assert.equal(pr, null);
|
||||
});
|
||||
|
||||
test('getPullRequest rethrows non-404 errors', async () => {
|
||||
const client = new GiteaClient({
|
||||
baseUrl: 'https://git.example.com',
|
||||
token: 't',
|
||||
fetchImpl: fakeFetch(async () => jsonResponse(500, { message: 'boom' })),
|
||||
});
|
||||
await assert.rejects(() => client.getPullRequest('o/r', 5), GiteaApiError);
|
||||
});
|
||||
|
||||
test('createIssueComment sends the expected method, path and body', async () => {
|
||||
let captured;
|
||||
const client = new GiteaClient({
|
||||
baseUrl: 'https://git.example.com/',
|
||||
token: 'secret-token',
|
||||
fetchImpl: fakeFetch(async (url, opts) => {
|
||||
captured = { url, opts };
|
||||
return jsonResponse(201, { id: 1 });
|
||||
}),
|
||||
});
|
||||
await client.createIssueComment('o/r', 9, 'hello');
|
||||
assert.equal(captured.url, 'https://git.example.com/api/v1/repos/o/r/issues/9/comments');
|
||||
assert.equal(captured.opts.method, 'POST');
|
||||
assert.equal(captured.opts.headers.Authorization, 'token secret-token');
|
||||
assert.deepEqual(JSON.parse(captured.opts.body), { body: 'hello' });
|
||||
});
|
||||
|
||||
test('authenticatedCloneUrl embeds credentials', () => {
|
||||
const client = new GiteaClient({ baseUrl: 'https://git.example.com', token: 'abc' });
|
||||
assert.equal(client.authenticatedCloneUrl('o/r'), 'https://oauth2:[email protected]/o/r.git');
|
||||
});
|
||||
|
||||
test('trailing slash on baseUrl does not produce a double slash', () => {
|
||||
const client = new GiteaClient({ baseUrl: 'https://git.example.com/', token: 'abc' });
|
||||
assert.equal(client.baseUrl, 'https://git.example.com');
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { JobRunner } from '../src/job.js';
|
||||
import { createLogger } from '../src/logger.js';
|
||||
|
||||
process.env.LOG_LEVEL = 'error'; // keep test output clean
|
||||
const logger = createLogger({});
|
||||
|
||||
function baseTrigger(overrides = {}) {
|
||||
return {
|
||||
repo: 'o/r',
|
||||
issueNumber: 3,
|
||||
issueTitle: 'Do a thing',
|
||||
issueBody: 'body',
|
||||
isPullRequest: false,
|
||||
defaultBranch: 'main',
|
||||
requestText: '@claude do the thing',
|
||||
author: 'selimaj-dev',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeGitea({ existingPRs = [] } = {}) {
|
||||
const calls = { comments: [], createdPRs: [] };
|
||||
return {
|
||||
baseUrl: 'https://git.example.com',
|
||||
token: 'tok',
|
||||
getPullRequest: async () => null,
|
||||
listOpenPullRequests: async () => existingPRs,
|
||||
createPullRequest: async (repo, body) => {
|
||||
calls.createdPRs.push({ repo, body });
|
||||
return { html_url: 'https://git.example.com/o/r/pulls/99' };
|
||||
},
|
||||
createIssueComment: async (repo, n, body) => {
|
||||
calls.comments.push({ repo, n, body });
|
||||
},
|
||||
calls,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeGitFactory({ hasChanges = false } = {}) {
|
||||
const calls = { cloned: false, checkedOutBranch: null, committed: null, pushed: null };
|
||||
const factory = () => ({
|
||||
clone: async () => { calls.cloned = true; },
|
||||
checkoutWorkBranch: async (b) => { calls.checkedOutBranch = b; },
|
||||
hasChanges: async () => hasChanges,
|
||||
commitAll: async (msg) => { calls.committed = msg; },
|
||||
push: async (b) => { calls.pushed = b; },
|
||||
});
|
||||
factory.calls = calls;
|
||||
return factory;
|
||||
}
|
||||
|
||||
test('posts a comment with claude output when nothing changed', async () => {
|
||||
const gitea = fakeGitea();
|
||||
const gitFactory = fakeGitFactory({ hasChanges: false });
|
||||
const claude = { runOnce: async () => 'Just an answer, no code change needed.' };
|
||||
const runner = new JobRunner({ gitea, claude, config: {}, logger, gitFactory });
|
||||
|
||||
await runner.run(baseTrigger());
|
||||
|
||||
assert.equal(gitea.calls.comments.length, 1);
|
||||
assert.match(gitea.calls.comments[0].body, /Just an answer/);
|
||||
assert.equal(gitFactory.calls.committed, null);
|
||||
assert.equal(gitea.calls.createdPRs.length, 0);
|
||||
});
|
||||
|
||||
test('commits, pushes and opens a PR when changes are made on an issue', async () => {
|
||||
const gitea = fakeGitea();
|
||||
const gitFactory = fakeGitFactory({ hasChanges: true });
|
||||
const claude = { runOnce: async () => 'Fixed it.' };
|
||||
const runner = new JobRunner({ gitea, claude, config: {}, logger, gitFactory });
|
||||
|
||||
await runner.run(baseTrigger());
|
||||
|
||||
assert.equal(gitFactory.calls.checkedOutBranch, 'claude/issue-3');
|
||||
assert.match(gitFactory.calls.committed, /#3/);
|
||||
assert.equal(gitFactory.calls.pushed, 'claude/issue-3');
|
||||
assert.equal(gitea.calls.createdPRs.length, 1);
|
||||
assert.match(gitea.calls.comments[0].body, /pulls\/99/);
|
||||
});
|
||||
|
||||
test('reuses an existing open PR instead of creating a duplicate', async () => {
|
||||
const gitea = fakeGitea({
|
||||
existingPRs: [{ head: { ref: 'claude/issue-3' }, html_url: 'https://git.example.com/o/r/pulls/7' }],
|
||||
});
|
||||
const gitFactory = fakeGitFactory({ hasChanges: true });
|
||||
const claude = { runOnce: async () => 'Fixed it.' };
|
||||
const runner = new JobRunner({ gitea, claude, config: {}, logger, gitFactory });
|
||||
|
||||
await runner.run(baseTrigger());
|
||||
|
||||
assert.equal(gitea.calls.createdPRs.length, 0);
|
||||
assert.match(gitea.calls.comments[0].body, /pulls\/7/);
|
||||
});
|
||||
|
||||
test('does not open a PR when the trigger is already a PR conversation', async () => {
|
||||
const gitea = fakeGitea();
|
||||
gitea.getPullRequest = async () => ({ head: { ref: 'existing-branch' } });
|
||||
const gitFactory = fakeGitFactory({ hasChanges: true });
|
||||
const claude = { runOnce: async () => 'Updated per review comment.' };
|
||||
const runner = new JobRunner({ gitea, claude, config: {}, logger, gitFactory });
|
||||
|
||||
await runner.run(baseTrigger({ isPullRequest: true }));
|
||||
|
||||
assert.equal(gitFactory.calls.pushed, 'existing-branch');
|
||||
assert.equal(gitFactory.calls.checkedOutBranch, null); // issue-only path skipped
|
||||
assert.equal(gitea.calls.createdPRs.length, 0);
|
||||
});
|
||||
|
||||
test('posts a failure comment when claude throws, and never throws itself', async () => {
|
||||
const gitea = fakeGitea();
|
||||
const gitFactory = fakeGitFactory();
|
||||
const claude = { runOnce: async () => { throw new Error('claude exploded'); } };
|
||||
const runner = new JobRunner({ gitea, claude, config: {}, logger, gitFactory });
|
||||
|
||||
await assert.doesNotReject(runner.run(baseTrigger()));
|
||||
|
||||
assert.equal(gitea.calls.comments.length, 1);
|
||||
assert.match(gitea.calls.comments[0].body, /claude exploded/);
|
||||
});
|
||||
|
||||
test('swallows an error thrown while posting the failure comment itself', async () => {
|
||||
const gitea = fakeGitea();
|
||||
gitea.createIssueComment = async () => { throw new Error('gitea is down'); };
|
||||
const gitFactory = fakeGitFactory();
|
||||
const claude = { runOnce: async () => { throw new Error('claude exploded'); } };
|
||||
const runner = new JobRunner({ gitea, claude, config: {}, logger, gitFactory });
|
||||
|
||||
await assert.doesNotReject(runner.run(baseTrigger()));
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { JobQueue } from '../src/jobQueue.js';
|
||||
import { createLogger } from '../src/logger.js';
|
||||
|
||||
const silentLogger = createLogger({}).child({});
|
||||
// Silence output for these tests by pointing at a level above error.
|
||||
process.env.LOG_LEVEL = 'error';
|
||||
|
||||
test('runs the job for a fresh key', async () => {
|
||||
const queue = new JobQueue(silentLogger);
|
||||
let ran = false;
|
||||
await queue.runExclusive('a', async () => {
|
||||
ran = true;
|
||||
});
|
||||
assert.equal(ran, true);
|
||||
assert.equal(queue.isRunning('a'), false);
|
||||
});
|
||||
|
||||
test('skips a second call for the same key while the first is running', async () => {
|
||||
const queue = new JobQueue(silentLogger);
|
||||
let resolveFirst;
|
||||
const first = queue.runExclusive('a', () => new Promise((r) => (resolveFirst = r)));
|
||||
|
||||
assert.equal(queue.isRunning('a'), true);
|
||||
let secondRan = false;
|
||||
await queue.runExclusive('a', async () => {
|
||||
secondRan = true;
|
||||
});
|
||||
assert.equal(secondRan, false);
|
||||
|
||||
resolveFirst();
|
||||
await first;
|
||||
assert.equal(queue.isRunning('a'), false);
|
||||
});
|
||||
|
||||
test('different keys run independently', async () => {
|
||||
const queue = new JobQueue(silentLogger);
|
||||
const order = [];
|
||||
let resolveA;
|
||||
const a = queue.runExclusive('a', () => new Promise((r) => (resolveA = r))).then(() => order.push('a'));
|
||||
const b = queue.runExclusive('b', async () => order.push('b'));
|
||||
await b;
|
||||
resolveA();
|
||||
await a;
|
||||
assert.deepEqual(order, ['b', 'a']);
|
||||
});
|
||||
|
||||
test('clears the running key even when the job throws', async () => {
|
||||
const queue = new JobQueue(silentLogger);
|
||||
await queue.runExclusive('a', async () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
assert.equal(queue.isRunning('a'), false);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
// Regression test: the logger must produce real JSON lines, not the
|
||||
// literal string "undefined" (a bug caused by passing JSON.stringify a
|
||||
// replacer *factory* instead of calling it first).
|
||||
test('logger prints a parseable JSON object with the expected fields, not "undefined"', () => {
|
||||
const script = `
|
||||
import { createLogger } from '${import.meta.resolve('../src/logger.js').replace('file://', '')}';
|
||||
createLogger({ component: 'x' }).info('hello', { foo: 'bar' });
|
||||
`;
|
||||
const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const line = result.stdout.trim();
|
||||
assert.notEqual(line, 'undefined');
|
||||
const parsed = JSON.parse(line);
|
||||
assert.equal(parsed.level, 'info');
|
||||
assert.equal(parsed.msg, 'hello');
|
||||
assert.equal(parsed.component, 'x');
|
||||
assert.equal(parsed.foo, 'bar');
|
||||
assert.ok(parsed.time);
|
||||
});
|
||||
|
||||
test('logger.child merges bindings and does not mutate the parent', () => {
|
||||
const script = `
|
||||
import { createLogger } from '${import.meta.resolve('../src/logger.js').replace('file://', '')}';
|
||||
const parent = createLogger({ a: 1 });
|
||||
const child = parent.child({ b: 2 });
|
||||
child.info('from child');
|
||||
parent.info('from parent');
|
||||
`;
|
||||
const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const [childLine, parentLine] = result.stdout.trim().split('\n').map((l) => JSON.parse(l));
|
||||
assert.deepEqual({ a: childLine.a, b: childLine.b }, { a: 1, b: 2 });
|
||||
assert.equal(parentLine.b, undefined);
|
||||
});
|
||||
|
||||
test('debug messages are suppressed at the default (info) log level', () => {
|
||||
const script = `
|
||||
import { createLogger } from '${import.meta.resolve('../src/logger.js').replace('file://', '')}';
|
||||
createLogger({}).debug('should not appear');
|
||||
createLogger({}).info('should appear');
|
||||
`;
|
||||
const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, LOG_LEVEL: 'info' },
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const lines = result.stdout.trim().split('\n').filter(Boolean);
|
||||
assert.equal(lines.length, 1);
|
||||
assert.equal(JSON.parse(lines[0]).msg, 'should appear');
|
||||
});
|
||||
|
||||
test('errors are logged with name/message/stack, not serialized to {}', () => {
|
||||
const script = `
|
||||
import { createLogger } from '${import.meta.resolve('../src/logger.js').replace('file://', '')}';
|
||||
createLogger({}).error('boom', { error: new Error('bad thing') });
|
||||
`;
|
||||
const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const parsed = JSON.parse(result.stderr.trim());
|
||||
assert.equal(parsed.error.message, 'bad thing');
|
||||
assert.equal(parsed.error.name, 'Error');
|
||||
assert.ok(parsed.error.stack);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import crypto from 'node:crypto';
|
||||
import { createServer } from '../src/server.js';
|
||||
import { createLogger } from '../src/logger.js';
|
||||
|
||||
process.env.LOG_LEVEL = 'error';
|
||||
const logger = createLogger({});
|
||||
|
||||
function baseConfig(overrides = {}) {
|
||||
return { ownerLogin: 'selimaj-dev', triggerPhrase: '@claude', webhookSecret: null, ...overrides };
|
||||
}
|
||||
|
||||
async function withServer(config, fn) {
|
||||
const triggers = [];
|
||||
const server = createServer({ config, logger, onTrigger: (t) => triggers.push(t) });
|
||||
await new Promise((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address();
|
||||
try {
|
||||
await fn(`http://127.0.0.1:${port}`, triggers);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
function issuePayload() {
|
||||
return {
|
||||
action: 'created',
|
||||
comment: { user: { login: 'selimaj-dev' }, body: '@claude fix it' },
|
||||
issue: { number: 1, title: 't', body: 'b' },
|
||||
repository: { full_name: 'o/r', default_branch: 'main' },
|
||||
};
|
||||
}
|
||||
|
||||
test('GET /healthz returns 200 without touching onTrigger', async () => {
|
||||
await withServer(baseConfig(), async (base, triggers) => {
|
||||
const res = await fetch(`${base}/healthz`);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(triggers.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects non-POST, non-health requests', async () => {
|
||||
await withServer(baseConfig(), async (base) => {
|
||||
const res = await fetch(base, { method: 'GET' });
|
||||
assert.equal(res.status, 405);
|
||||
});
|
||||
});
|
||||
|
||||
test('accepts a matching issue_comment and calls onTrigger', async () => {
|
||||
await withServer(baseConfig(), async (base, triggers) => {
|
||||
const res = await fetch(base, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Gitea-Event': 'issue_comment', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(issuePayload()),
|
||||
});
|
||||
assert.equal(res.status, 202);
|
||||
assert.equal(triggers.length, 1);
|
||||
assert.equal(triggers[0].repo, 'o/r');
|
||||
});
|
||||
});
|
||||
|
||||
test('does not call onTrigger for an unrelated author', async () => {
|
||||
await withServer(baseConfig(), async (base, triggers) => {
|
||||
const payload = issuePayload();
|
||||
payload.comment.user.login = 'someone-else';
|
||||
await fetch(base, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Gitea-Event': 'issue_comment' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
assert.equal(triggers.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('accepts requests with no event header (health probes) without error', async () => {
|
||||
await withServer(baseConfig(), async (base, triggers) => {
|
||||
const res = await fetch(base, { method: 'POST', body: '{}' });
|
||||
assert.equal(res.status, 202);
|
||||
assert.equal(triggers.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects a request with a bad signature when a secret is configured', async () => {
|
||||
await withServer(baseConfig({ webhookSecret: 's3cret' }), async (base, triggers) => {
|
||||
const res = await fetch(base, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Gitea-Event': 'issue_comment', 'X-Gitea-Signature': 'wrong' },
|
||||
body: JSON.stringify(issuePayload()),
|
||||
});
|
||||
assert.equal(res.status, 401);
|
||||
assert.equal(triggers.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('accepts a request with a correct signature when a secret is configured', async () => {
|
||||
const secret = 's3cret';
|
||||
await withServer(baseConfig({ webhookSecret: secret }), async (base, triggers) => {
|
||||
const body = JSON.stringify(issuePayload());
|
||||
const sig = crypto.createHmac('sha256', secret).update(body).digest('hex');
|
||||
const res = await fetch(base, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Gitea-Event': 'issue_comment', 'X-Gitea-Signature': sig },
|
||||
body,
|
||||
});
|
||||
assert.equal(res.status, 202);
|
||||
assert.equal(triggers.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('does not crash on invalid JSON body', async () => {
|
||||
await withServer(baseConfig(), async (base, triggers) => {
|
||||
const res = await fetch(base, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Gitea-Event': 'issue_comment' },
|
||||
body: 'not json',
|
||||
});
|
||||
assert.equal(res.status, 202); // already acknowledged before parse failure
|
||||
assert.equal(triggers.length, 0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user