stoke/test/cli.test.js
codex-bot-andresmgsl ed3f234b8e
Some checks failed
labels / labels (pull_request) Successful in 12s
ci / test (pull_request) Failing after 2m50s
test: cover unauthenticated auth state
2026-09-04 01:55:12 +00:00

1140 lines
45 KiB
JavaScript

const { test } = require('node:test');
const assert = require('node:assert/strict');
const { execFileSync, spawn, spawnSync } = require('node:child_process');
const fs = require('node:fs');
const http = require('node:http');
const os = require('node:os');
const path = require('node:path');
const CLI = path.join(__dirname, '..', 'src', 'cli.js');
const pkg = require('../package.json');
function run(args, env = {}) {
return spawnSync(process.execPath, [CLI, ...args], {
encoding: 'utf8',
env: { ...process.env, ...env },
});
}
test('--version matches package.json', () => {
const out = execFileSync(process.execPath, [CLI, '--version'], { encoding: 'utf8' });
assert.equal(out.trim(), pkg.version);
});
test('--help lists every top-level command', () => {
const out = execFileSync(process.execPath, [CLI, '--help'], { encoding: 'utf8' });
for (const cmd of ['auth', 'repo', 'issue', 'pr', 'release', 'label', 'branch', 'collaborator', 'org', 'user', 'api']) {
assert.match(out, new RegExp(`^\\s+${cmd}`, 'm'), `missing command: ${cmd}`);
}
});
test('unauthenticated commands fail with a login hint', () => {
const missing = path.join(os.tmpdir(), `stoke-none-${process.pid}.json`);
const res = run(['repo', 'list'], { STOKE_CONFIG_FILE: missing });
assert.equal(res.status, 1);
assert.match(res.stderr, /stoke auth login/);
});
test('global --config flag overrides the config location', () => {
// Point --config at a nonexistent file: auth status must report
// "Not authenticated" instead of silently using the default config.
const missing = path.join(os.tmpdir(), `stoke-missing-${process.pid}.json`);
const res = run(['--config', missing, 'auth', 'status']);
assert.equal(res.status, 1);
assert.match(res.stdout, /Not authenticated/);
});
test('auth status reports an absent session in text and JSON with a failing status', () => {
const missing = path.join(os.tmpdir(), `stoke-missing-${process.pid}-auth-status.json`);
const text = run(['auth', 'status'], { STOKE_CONFIG_FILE: missing });
assert.equal(text.status, 1);
assert.equal(text.stdout, 'Not authenticated.\n');
assert.equal(text.stderr, '');
const json = run(['auth', 'status', '--json'], { STOKE_CONFIG_FILE: missing });
assert.equal(json.status, 1);
assert.equal(json.stdout, '{"authenticated":false}\n');
assert.equal(json.stderr, '');
});
test('auth logout identifies a supplied token that remains active without changing local-only output', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-auth-logout-'));
const cfg = path.join(dir, 'config.json');
const config = {
url: 'https://forge.test',
login: 'bot',
username: 'bot',
token: 'token-that-must-not-be-printed',
tokenId: null,
};
try {
fs.writeFileSync(cfg, JSON.stringify(config));
const logout = run(['auth', 'logout'], { STOKE_CONFIG_FILE: cfg });
assert.equal(logout.status, 0, logout.stderr);
assert.match(logout.stdout, /local credentials/i);
assert.match(logout.stdout, /did not create this token/i);
assert.match(logout.stdout, /cannot revoke it/i);
assert.match(logout.stdout, /still valid on https:\/\/forge\.test/i);
assert.match(logout.stdout, /Settings > Applications/);
assert.doesNotMatch(logout.stdout, /token-that-must-not-be-printed/);
assert.equal(fs.existsSync(cfg), false);
fs.writeFileSync(cfg, JSON.stringify(config));
const localOnly = run(['auth', 'logout', '--local-only'], { STOKE_CONFIG_FILE: cfg });
assert.equal(localOnly.status, 0, localOnly.stderr);
assert.equal(localOnly.stdout, 'Local credentials removed.\n');
assert.equal(localOnly.stderr, '');
assert.equal(fs.existsSync(cfg), false);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('invalid --limit is rejected before any network call', () => {
const res = run(['repo', 'list', '-l', 'abc']);
assert.equal(res.status, 1);
assert.match(res.stderr, /Limit must be a non-negative integer/);
});
test('invalid --team-id is rejected before any network call', () => {
const res = run(['org', 'team', 'member-add', '--team-id', 'zero', '-u', 'x']);
assert.equal(res.status, 1);
assert.match(res.stderr, /Id must be a positive integer/);
});
test('repo create help lists the owner option', () => {
const res = run(['repo', 'create', '--help']);
assert.equal(res.status, 0, res.stderr);
assert.match(res.stdout, /-o, --owner <owner>/);
});
test('release asset commands expose repeatable assets and a single-asset name override', () => {
const create = run(['release', 'create', '--help']);
assert.equal(create.status, 0, create.stderr);
assert.match(create.stdout, /--asset <path>/);
assert.match(create.stdout, /--asset-name <name>/);
const upload = run(['release', 'upload', '--help']);
assert.equal(upload.status, 0, upload.stderr);
assert.match(upload.stdout, /--tag <tag>/);
assert.match(upload.stdout, /--asset <path>/);
assert.match(upload.stdout, /--asset-name <name>/);
});
test('release create rejects one asset name for multiple assets before reading config', () => {
const res = run([
'release', 'create', '-o', 'o', '-r', 'r', '--tag', 'v1',
'--asset', 'one.bin', '--asset', 'two.bin', '--asset-name', 'named.bin',
], { STOKE_CONFIG_FILE: path.join(os.tmpdir(), `stoke-none-${process.pid}-release.json`) });
assert.equal(res.status, 1);
assert.match(res.stderr, /--asset-name requires exactly one --asset/);
assert.doesNotMatch(res.stderr, /Not authenticated/);
});
test('release upload rejects zero assets before reading config', () => {
const res = run([
'release', 'upload', '-o', 'o', '-r', 'r', '--tag', 'v1',
], { STOKE_CONFIG_FILE: path.join(os.tmpdir(), `stoke-none-${process.pid}-release.json`) });
assert.equal(res.status, 1);
assert.match(res.stderr, /at least one --asset is required/i);
assert.doesNotMatch(res.stderr, /Not authenticated/);
});
test('release create prints the id and uploads every asset as multipart data', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-release-create-'));
const cfg = path.join(dir, 'config.json');
const first = path.join(dir, 'first.bin');
const second = path.join(dir, 'second.bin');
fs.writeFileSync(first, 'first payload');
fs.writeFileSync(second, 'second payload');
const requests = [];
const server = http.createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
requests.push({
method: req.method,
url: req.url,
contentType: req.headers['content-type'],
body: Buffer.concat(chunks).toString('utf8'),
});
res.setHeader('Content-Type', 'application/json');
if (req.url === '/api/v1/repos/o/r/releases') {
res.writeHead(201);
res.end(JSON.stringify({ id: 42, tag_name: 'v1', name: 'Version 1', html_url: 'https://forge.test/o/r/releases/v1' }));
} else {
res.writeHead(201);
res.end(JSON.stringify({ id: requests.length, name: new URL(req.url, 'http://local').searchParams.get('name') }));
}
});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${server.address().port}`, token: 'tok' }));
try {
const result = await spawnAsync([
'release', 'create', '-o', 'o', '-r', 'r', '--tag', 'v1',
'--asset', first, '--asset', second,
], { STOKE_CONFIG_FILE: cfg });
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /Release id: 42/i);
assert.match(result.stdout, /Asset uploaded: first\.bin/);
assert.match(result.stdout, /Asset uploaded: second\.bin/);
assert.deepEqual(requests.map(({ method, url }) => ({ method, url })), [
{ method: 'POST', url: '/api/v1/repos/o/r/releases' },
{ method: 'POST', url: '/api/v1/repos/o/r/releases/42/assets?name=first.bin' },
{ method: 'POST', url: '/api/v1/repos/o/r/releases/42/assets?name=second.bin' },
]);
assert.match(requests[1].contentType, /^multipart\/form-data; boundary=/);
assert.match(requests[1].body, /first payload/);
assert.match(requests[2].body, /second payload/);
} finally {
await new Promise((resolve) => server.close(resolve));
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('release upload resolves the tag once and applies a single asset name override', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-release-upload-'));
const cfg = path.join(dir, 'config.json');
const asset = path.join(dir, 'original.bin');
fs.writeFileSync(asset, 'upload payload');
const requests = [];
const server = http.createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
requests.push({ method: req.method, url: req.url, body: Buffer.concat(chunks).toString('utf8') });
res.setHeader('Content-Type', 'application/json');
if (req.method === 'GET') {
res.end(JSON.stringify({ id: 7, tag_name: 'v1' }));
} else {
res.writeHead(201);
res.end(JSON.stringify({ id: 8, name: 'renamed.bin' }));
}
});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${server.address().port}`, token: 'tok' }));
try {
const result = await spawnAsync([
'release', 'upload', '-o', 'o', '-r', 'r', '--tag', 'v1',
'--asset', asset, '--asset-name', 'renamed.bin',
], { STOKE_CONFIG_FILE: cfg });
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /Asset uploaded: renamed\.bin/);
assert.deepEqual(requests.map(({ method, url }) => ({ method, url })), [
{ method: 'GET', url: '/api/v1/repos/o/r/releases/tags/v1' },
{ method: 'POST', url: '/api/v1/repos/o/r/releases/7/assets?name=renamed.bin' },
]);
assert.match(requests[1].body, /upload payload/);
} finally {
await new Promise((resolve) => server.close(resolve));
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('release upload streams a large asset through receiver backpressure', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-release-large-upload-'));
const cfg = path.join(dir, 'config.json');
const asset = path.join(dir, 'large.bin');
const assetSize = 8 * 1024 * 1024;
fs.writeFileSync(asset, Buffer.alloc(assetSize, 0x61));
let uploadedBytes = 0;
let paused = false;
const server = http.createServer((req, res) => {
if (req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: 7, tag_name: 'v1' }));
return;
}
req.on('data', (chunk) => {
uploadedBytes += chunk.length;
if (!paused) {
paused = true;
req.pause();
setTimeout(() => req.resume(), 100);
}
});
req.on('end', () => {
res.writeHead(201, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: 8, name: 'large.bin' }));
});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${server.address().port}`, token: 'tok' }));
try {
const result = await spawnAsync(
['release', 'upload', '-o', 'o', '-r', 'r', '--tag', 'v1', '--asset', asset],
{ STOKE_CONFIG_FILE: cfg },
);
assert.equal(result.status, 0, result.stderr);
assert.equal(paused, true);
assert.ok(uploadedBytes > assetSize, `multipart body ${uploadedBytes} did not include ${assetSize} asset bytes`);
assert.match(result.stdout, /Asset uploaded: large\.bin/);
} finally {
await new Promise((resolve) => server.close(resolve));
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('release create keeps the release and reports landed and failed assets', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-release-partial-'));
const cfg = path.join(dir, 'config.json');
const good = path.join(dir, 'good.bin');
const bad = path.join(dir, 'bad.bin');
fs.writeFileSync(good, 'good');
fs.writeFileSync(bad, 'bad');
const requests = [];
const server = http.createServer((req, res) => {
req.resume();
req.on('end', () => {
requests.push({ method: req.method, url: req.url });
res.setHeader('Content-Type', 'application/json');
if (req.url === '/api/v1/repos/o/r/releases') {
res.writeHead(201);
res.end(JSON.stringify({ id: 42, tag_name: 'v1', name: 'v1', html_url: 'https://forge.test/release/v1' }));
} else if (req.url.includes('good.bin')) {
res.writeHead(201);
res.end(JSON.stringify({ id: 1, name: 'good.bin' }));
} else {
res.writeHead(500);
res.end(JSON.stringify({ message: 'storage unavailable' }));
}
});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${server.address().port}`, token: 'tok' }));
try {
const result = await spawnAsync([
'release', 'create', '-o', 'o', '-r', 'r', '--tag', 'v1',
'--asset', bad, '--asset', good,
], { STOKE_CONFIG_FILE: cfg });
assert.equal(result.status, 1);
assert.match(result.stdout, /Release id: 42/i);
assert.match(result.stdout, /Asset uploaded: good\.bin/);
assert.match(result.stderr, /Asset failed: bad\.bin: storage unavailable/);
assert.match(result.stderr, /release was kept/i);
assert.deepEqual(requests.map(({ url }) => url), [
'/api/v1/repos/o/r/releases',
'/api/v1/repos/o/r/releases/42/assets?name=bad.bin',
'/api/v1/repos/o/r/releases/42/assets?name=good.bin',
]);
} finally {
await new Promise((resolve) => server.close(resolve));
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('release view lists attached assets with their sizes and download URLs', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-release-view-assets-'));
const cfg = path.join(dir, 'config.json');
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
id: 42,
tag_name: 'v1',
name: 'Version 1',
html_url: 'https://forge.test/o/r/releases/v1',
target_commitish: 'main',
author: { login: 'bot' },
published_at: '2026-08-30T00:00:00Z',
body: '',
assets: [
{ name: 'first.bin', size: 12, browser_download_url: 'https://forge.test/assets/first.bin' },
{ name: 'second.bin', size: 2048, browser_download_url: 'https://forge.test/assets/second.bin' },
],
}));
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${server.address().port}`, token: 'tok' }));
try {
const result = await spawnAsync(
['release', 'view', '-o', 'o', '-r', 'r', '--tag', 'v1'],
{ STOKE_CONFIG_FILE: cfg },
);
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /Assets:/);
assert.match(result.stdout, /first\.bin \(12 bytes\) https:\/\/forge\.test\/assets\/first\.bin/);
assert.match(result.stdout, /second\.bin \(2048 bytes\) https:\/\/forge\.test\/assets\/second\.bin/);
} finally {
await new Promise((resolve) => server.close(resolve));
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('repo create surfaces an organization permission failure and HTTP status', async () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-repo-create-403.json`);
const requests = [];
const server = http.createServer((req, res) => {
requests.push({ method: req.method, url: req.url });
res.setHeader('Content-Type', 'application/json');
if (req.url === '/api/v1/user') {
res.writeHead(200);
res.end(JSON.stringify({ login: 'buildbot' }));
return;
}
res.writeHead(403);
res.end(JSON.stringify({ message: 'user does not have permission to create repositories' }));
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
try {
const res = await spawnAsync(
['repo', 'create', '--owner', 'heavy-duty', '--name', 'project'],
{ STOKE_CONFIG_FILE: cfg },
);
assert.equal(res.status, 1);
assert.match(res.stderr, /user does not have permission to create repositories/);
assert.match(res.stderr, /HTTP status: 403/);
assert.deepEqual(requests, [
{ method: 'GET', url: '/api/v1/user' },
{ method: 'POST', url: '/api/v1/orgs/heavy-duty/repos' },
]);
} finally {
await new Promise((resolve) => server.close(resolve));
fs.unlinkSync(cfg);
}
});
test('pr merge validates --number before any network call', () => {
const res = run(['pr', 'merge', '-o', 'o', '-r', 'r', '-n', 'seven']);
assert.equal(res.status, 1);
assert.match(res.stderr, /Id must be a positive integer/);
});
test('issue create --body-file reports unreadable files cleanly', () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}.json`);
fs.writeFileSync(cfg, JSON.stringify({ url: 'https://forge.test', token: 'tok' }));
try {
const res = run(
['issue', 'create', '-o', 'o', '-r', 'r', '-t', 't', '--body-file', '/nonexistent/body.md'],
{ STOKE_CONFIG_FILE: cfg },
);
assert.equal(res.status, 1);
assert.match(res.stderr, /Could not read body file/);
} finally {
fs.unlinkSync(cfg);
}
});
test('issue create resolves repeated label names into the initial create payload', async () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-issue-labels.json`);
const requests = [];
const server = http.createServer((req, res) => {
let data = '';
req.on('data', (chunk) => { data += chunk; });
req.on('end', () => {
requests.push({ method: req.method, url: req.url, body: data });
res.writeHead(req.method === 'POST' ? 201 : 200, { 'Content-Type': 'application/json' });
if (req.method === 'GET') {
res.end(JSON.stringify([
{ id: 107, name: 'ready', color: '0e8a16' },
{ id: 100, name: 'enhancement', color: '84b6eb' },
]));
} else {
res.end(JSON.stringify({ number: 27, title: 'Probe', html_url: 'https://forge.test/o/r/issues/27' }));
}
});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
try {
const res = await spawnAsync(
['issue', 'create', '-o', 'o', '-r', 'r', '-t', 'Probe', '--label', 'ready', '--label', 'enhancement'],
{ STOKE_CONFIG_FILE: cfg },
);
assert.equal(res.status, 0, res.stderr);
assert.deepEqual(requests.map(({ method }) => method), ['GET', 'POST']);
assert.match(requests[0].url, /^\/api\/v1\/repos\/o\/r\/labels\?/);
assert.equal(requests[1].url, '/api/v1/repos/o/r/issues');
assert.deepEqual(JSON.parse(requests[1].body), {
title: 'Probe',
body: '',
labels: [107, 100],
});
} finally {
await new Promise((resolve) => server.close(resolve));
fs.unlinkSync(cfg);
}
});
test('issue create rejects an unknown label before creating the issue', async () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-unknown-issue-label.json`);
let createRequests = 0;
const server = http.createServer((req, res) => {
if (req.method === 'POST') createRequests += 1;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify([{ id: 107, name: 'ready', color: '0e8a16' }]));
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
try {
const res = await spawnAsync(
['issue', 'create', '-o', 'o', '-r', 'r', '-t', 'Probe', '--label', 'nonexistent'],
{ STOKE_CONFIG_FILE: cfg },
);
assert.equal(res.status, 1);
assert.match(res.stderr, /Label not found in o\/r: nonexistent/);
assert.equal(createRequests, 0);
} finally {
await new Promise((resolve) => server.close(resolve));
fs.unlinkSync(cfg);
}
});
test('pr show validates --number before any network call', () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}.json`);
fs.writeFileSync(cfg, JSON.stringify({ url: 'https://forge.test', token: 'tok' }));
try {
const res = run(['pr', 'show', '-o', 'o', '-r', 'r', '-n', 'zero'], { STOKE_CONFIG_FILE: cfg });
assert.equal(res.status, 1);
assert.match(res.stderr, /Id must be a positive integer/);
} finally {
fs.unlinkSync(cfg);
}
});
test('pr comment rejects a missing body before any network call', () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}.json`);
fs.writeFileSync(cfg, JSON.stringify({ url: 'https://forge.test', token: 'tok' }));
try {
const res = run(['pr', 'comment', '-o', 'o', '-r', 'r', '-n', '1'], { STOKE_CONFIG_FILE: cfg });
assert.equal(res.status, 1);
assert.match(res.stderr, /Comment body is required/);
} finally {
fs.unlinkSync(cfg);
}
});
test('pr comment rejects a whitespace-only body before any network call', () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}.json`);
fs.writeFileSync(cfg, JSON.stringify({ url: 'https://forge.test', token: 'tok' }));
try {
const res = run(['pr', 'comment', '-o', 'o', '-r', 'r', '-n', '1', '-b', ' '], { STOKE_CONFIG_FILE: cfg });
assert.equal(res.status, 1);
assert.match(res.stderr, /Comment body is required/);
} finally {
fs.unlinkSync(cfg);
}
});
test('pr review rejects an invalid event before any network call', () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}.json`);
fs.writeFileSync(cfg, JSON.stringify({ url: 'https://forge.test', token: 'tok' }));
try {
const res = run(['pr', 'review', '-o', 'o', '-r', 'r', '-n', '1', '--event', 'nope'], { STOKE_CONFIG_FILE: cfg });
assert.equal(res.status, 1);
assert.match(res.stderr, /Invalid review event/);
} finally {
fs.unlinkSync(cfg);
}
});
test('pr review accepts approved as an alias for approve before any network call', () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}.json`);
fs.writeFileSync(cfg, JSON.stringify({ url: 'https://forge.test', token: 'tok' }));
try {
// Network fails; must not fail at event validation.
const res = run(['pr', 'review', '-o', 'o', '-r', 'r', '-n', '1', '--event', 'APPROVED'], { STOKE_CONFIG_FILE: cfg });
assert.equal(res.status, 1);
assert.doesNotMatch(res.stderr, /Invalid review event/);
assert.doesNotMatch(res.stderr, /requires a non-empty body/);
} finally {
fs.unlinkSync(cfg);
}
});
test('pr review request-changes rejects a missing body before any network call', () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}.json`);
fs.writeFileSync(cfg, JSON.stringify({ url: 'https://forge.test', token: 'tok' }));
try {
const res = run(['pr', 'review', '-o', 'o', '-r', 'r', '-n', '1', '--event', 'request-changes'], { STOKE_CONFIG_FILE: cfg });
assert.equal(res.status, 1);
assert.match(res.stderr, /requires a non-empty body/);
} finally {
fs.unlinkSync(cfg);
}
});
test('pr review comment rejects a whitespace-only body before any network call', () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}.json`);
fs.writeFileSync(cfg, JSON.stringify({ url: 'https://forge.test', token: 'tok' }));
try {
const res = run(['pr', 'review', '-o', 'o', '-r', 'r', '-n', '1', '--event', 'comment', '-b', ' '], { STOKE_CONFIG_FILE: cfg });
assert.equal(res.status, 1);
assert.match(res.stderr, /requires a non-empty body/);
} finally {
fs.unlinkSync(cfg);
}
});
test('pr review approve allows an empty body before any network call', () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}.json`);
fs.writeFileSync(cfg, JSON.stringify({ url: 'https://forge.test', token: 'tok' }));
try {
const res = run(['pr', 'review', '-o', 'o', '-r', 'r', '-n', '1', '--event', 'approve'], { STOKE_CONFIG_FILE: cfg });
// It fails at the network call, not at validation.
assert.equal(res.status, 1);
assert.doesNotMatch(res.stderr, /requires a non-empty body/);
} finally {
fs.unlinkSync(cfg);
}
});
test('label create rejects an invalid color before any network call', () => {
const res = run(['label', 'create', '-o', 'o', '-r', 'r', '--name', 'x', '--color', 'red']);
assert.equal(res.status, 1);
assert.match(res.stderr, /Color must be 6 hex digits/);
});
test('label delete requires one of --id or --name before any network call', () => {
const res = run(['label', 'delete', '-o', 'o', '-r', 'r']);
assert.equal(res.status, 1);
assert.match(res.stderr, /One of --id or --name is required/);
});
test('api rejects an endpoint without a leading slash before any network call', () => {
const res = run(['api', 'repos/o/r']);
assert.equal(res.status, 1);
assert.match(res.stderr, /Endpoint must start with \//);
});
test('api rejects an unsupported method before any network call', () => {
const res = run(['api', '/user', '-X', 'HEAD']);
assert.equal(res.status, 1);
assert.match(res.stderr, /Unsupported method/);
});
test('api rejects --paginate with a non-GET method before any network call', () => {
const res = run(['api', '/user', '-X', 'POST', '--paginate']);
assert.equal(res.status, 1);
assert.match(res.stderr, /--paginate only works with GET/);
});
test('api rejects GET with --input before any network call', () => {
const res = run(['api', '/user', '-X', 'GET', '--input', '{}']);
assert.equal(res.status, 1);
assert.match(res.stderr, /GET requests cannot carry a body/);
});
test('label delete rejects --id and --name together before any network call', () => {
const res = run(['label', 'delete', '-o', 'o', '-r', 'r', '--id', '3', '--name', 'x']);
assert.equal(res.status, 1);
assert.match(res.stderr, /either --id or --name, not both/);
});
test('api rejects invalid JSON input before any network call', () => {
const res = run(['api', '/user', '--input', '{nope']);
assert.equal(res.status, 1);
assert.match(res.stderr, /not valid JSON/);
});
test('api sends the token and prints the JSON response', async () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-api.json`);
const TIMEOUT_MS = 5000;
let timer;
const result = await new Promise((resolve, reject) => {
const fail = (err) => {
clearTimeout(timer);
try { server.close(); } catch { /* already closed */ }
reject(err instanceof Error ? err : new Error(String(err)));
};
let authHeader = null;
const server = http.createServer((req, res) => {
authHeader = req.headers.authorization;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ login: 'bot' }));
});
timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS);
server.listen(0, '127.0.0.1', async () => {
const { port } = server.address();
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
try {
const res = await spawnAsync(['api', '/user'], { STOKE_CONFIG_FILE: cfg });
clearTimeout(timer);
server.close(() => resolve({ res, authHeader }));
} catch (err) {
fail(err);
}
});
}).finally(() => clearTimeout(timer));
try {
assert.equal(result.res.status, 0, result.res.stderr);
assert.equal(result.authHeader, 'token tok');
assert.deepEqual(JSON.parse(result.res.stdout), { login: 'bot' });
} finally {
fs.unlinkSync(cfg);
}
});
test('label add fails closed on an unknown label name', async () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-lbl.json`);
const TIMEOUT_MS = 5000;
let timer;
const result = await new Promise((resolve, reject) => {
const fail = (err) => {
clearTimeout(timer);
try { server.close(); } catch { /* already closed */ }
reject(err instanceof Error ? err : new Error(String(err)));
};
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify([{ id: 1, name: 'bug', color: 'd73a4a' }]));
});
timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS);
server.listen(0, '127.0.0.1', async () => {
const { port } = server.address();
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
try {
const res = await spawnAsync(
['label', 'add', '-o', 'o', '-r', 'r', '-n', '7', '--name', 'ghost'],
{ STOKE_CONFIG_FILE: cfg },
);
clearTimeout(timer);
server.close(() => resolve(res));
} catch (err) {
fail(err);
}
});
}).finally(() => clearTimeout(timer));
try {
assert.equal(result.status, 1);
assert.match(result.stderr, /Label not found in o\/r: ghost/);
} finally {
fs.unlinkSync(cfg);
}
});
function spawnAsync(args, env = {}) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [CLI, ...args], {
env: { ...process.env, ...env },
});
let stdout = '';
let stderr = '';
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk) => { stdout += chunk; });
child.stderr.on('data', (chunk) => { stderr += chunk; });
child.on('error', reject);
child.on('close', (status) => resolve({ status, stdout, stderr }));
});
}
test('pr review preserves exact body-file whitespace through the CLI boundary', async () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}.json`);
const bodyFile = path.join(os.tmpdir(), `stoke-body-${process.pid}.md`);
const rawBody = ' leading spaces\nline\ntrailing newline\n';
fs.writeFileSync(bodyFile, rawBody, 'utf8');
const TIMEOUT_MS = 5000;
let timer;
const captured = await new Promise((resolve, reject) => {
const fail = (err) => {
clearTimeout(timer);
try { server.close(); } catch { /* already closed */ }
reject(err instanceof Error ? err : new Error(String(err)));
};
const server = http.createServer((req, res) => {
let data = '';
req.setEncoding('utf8');
req.on('data', (chunk) => { data += chunk; });
req.on('end', () => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: 99, html_url: 'https://forge.test/reviews/99' }));
clearTimeout(timer);
server.close(() => resolve({ url: req.url, body: data, cliStatus: null }));
});
});
timer = setTimeout(() => fail(new Error(`CLI boundary test timed out after ${TIMEOUT_MS}ms`)), TIMEOUT_MS);
server.listen(0, '127.0.0.1', async () => {
const { port } = server.address();
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
try {
const res = await spawnAsync(
['pr', 'review', '-o', 'o', '-r', 'r', '-n', '7', '--event', 'request-changes', '--body-file', bodyFile],
{ STOKE_CONFIG_FILE: cfg },
);
if (res.status !== 0) {
fail(new Error(`CLI failed (status ${res.status}): ${res.stderr}`));
return;
}
// Capture is resolved from the HTTP handler; assert exit 0 here via side channel.
// If the handler already resolved, attach status for the outer asserts.
} catch (err) {
fail(err);
}
});
}).finally(() => clearTimeout(timer));
try {
assert.equal(captured.url, '/api/v1/repos/o/r/pulls/7/reviews');
const json = JSON.parse(captured.body);
assert.equal(json.event, 'REQUEST_CHANGES');
assert.equal(json.body, rawBody);
} finally {
fs.unlinkSync(cfg);
fs.unlinkSync(bodyFile);
}
});
test('pr review prints the review URL from the API response', async () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-url.json`);
const TIMEOUT_MS = 5000;
let timer;
const result = await new Promise((resolve, reject) => {
const fail = (err) => {
clearTimeout(timer);
try { server.close(); } catch { /* already closed */ }
reject(err instanceof Error ? err : new Error(String(err)));
};
const server = http.createServer((req, res) => {
let data = '';
req.on('data', (c) => { data += c; });
req.on('end', () => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: 42, html_url: 'https://forge.test/pulls/7#issuecomment-42' }));
});
});
timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS);
server.listen(0, '127.0.0.1', async () => {
const { port } = server.address();
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
try {
const res = await spawnAsync(
['pr', 'review', '-o', 'o', '-r', 'r', '-n', '7', '--event', 'approve', '-b', 'LGTM'],
{ STOKE_CONFIG_FILE: cfg },
);
clearTimeout(timer);
server.close(() => resolve(res));
} catch (err) {
fail(err);
}
});
}).finally(() => clearTimeout(timer));
try {
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /Review submitted on !7: APPROVED/);
assert.match(result.stdout, /URL: https:\/\/forge\.test\/pulls\/7#issuecomment-42/);
} finally {
fs.unlinkSync(cfg);
}
});
// Runs `auth login` against a stub Forgejo server and captures the body of
// the token-creation request. GETs answer as /user; the POST to
// /users/{name}/tokens is what carries the scopes under test.
function runLoginWithServer(extraArgs) {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-${runLoginWithServer.n}.json`);
runLoginWithServer.n += 1;
const TIMEOUT_MS = 5000;
let timer;
return new Promise((resolve, reject) => {
const fail = (err) => {
clearTimeout(timer);
try { server.close(); } catch { /* already closed */ }
reject(err instanceof Error ? err : new Error(String(err)));
};
let tokenBody = null;
const server = http.createServer((req, res) => {
if (req.method === 'POST' && req.url.startsWith('/api/v1/users/')) {
let data = '';
req.on('data', (c) => { data += c; });
req.on('end', () => {
tokenBody = JSON.parse(data);
res.writeHead(201, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: 1, name: tokenBody.name, sha1: 'tok123' }));
});
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ login: 'alice', username: 'alice', email: 'alice@forge.test' }));
});
timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS);
server.listen(0, '127.0.0.1', async () => {
const { port } = server.address();
try {
const res = await spawnAsync(
['auth', 'login', '-u', `http://127.0.0.1:${port}`, '-n', 'alice', '-p', 'secret', ...extraArgs],
{ STOKE_CONFIG_FILE: cfg },
);
clearTimeout(timer);
server.close(() => resolve({ res, tokenBody, cfg }));
} catch (err) {
fail(err);
}
});
}).finally(() => clearTimeout(timer));
}
runLoginWithServer.n = 0;
test('auth login creates a token with the reduced default scopes', async () => {
const { res, tokenBody, cfg } = await runLoginWithServer([]);
try {
assert.equal(res.status, 0, res.stderr);
assert.deepEqual(tokenBody.scopes, [
'read:issue', 'write:issue',
'read:repository', 'write:repository',
'read:user',
'read:organization',
]);
assert.match(res.stdout, /Scopes: read:issue, write:issue, read:repository, write:repository, read:user, read:organization/);
} finally {
fs.unlinkSync(cfg);
}
});
test('auth login --full-scopes restores the full scope set', async () => {
const { res, tokenBody, cfg } = await runLoginWithServer(['--full-scopes']);
try {
assert.equal(res.status, 0, res.stderr);
assert.deepEqual(tokenBody.scopes, [
'read:activitypub', 'write:activitypub',
'read:issue', 'write:issue',
'read:misc', 'write:misc',
'read:organization', 'write:organization',
'read:package', 'write:package',
'read:repository', 'write:repository',
'read:user', 'write:user',
]);
} finally {
fs.unlinkSync(cfg);
}
});
test('auth login --scopes parses a comma-separated list', async () => {
const { res, tokenBody, cfg } = await runLoginWithServer(['--scopes', 'read:issue, write:repository ,read:user']);
try {
assert.equal(res.status, 0, res.stderr);
assert.deepEqual(tokenBody.scopes, ['read:issue', 'write:repository', 'read:user']);
assert.match(res.stdout, /Scopes: read:issue, write:repository, read:user/);
} finally {
fs.unlinkSync(cfg);
}
});
test('auth login rejects --full-scopes together with --scopes before any network call', () => {
const res = run(['auth', 'login', '--full-scopes', '--scopes', 'read:issue']);
assert.equal(res.status, 1);
assert.match(res.stderr, /either --full-scopes or --scopes, not both/);
});
test('issue show validates --number before any network call', () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}.json`);
fs.writeFileSync(cfg, JSON.stringify({ url: 'https://forge.test', token: 'tok' }));
try {
const res = run(['issue', 'show', '-o', 'o', '-r', 'r', '-n', 'zero'], { STOKE_CONFIG_FILE: cfg });
assert.equal(res.status, 1);
assert.match(res.stderr, /Id must be a positive integer/);
} finally {
fs.unlinkSync(cfg);
}
});
test('issue comment rejects a missing body before any network call', () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}.json`);
fs.writeFileSync(cfg, JSON.stringify({ url: 'https://forge.test', token: 'tok' }));
try {
const res = run(['issue', 'comment', '-o', 'o', '-r', 'r', '-n', '1'], { STOKE_CONFIG_FILE: cfg });
assert.equal(res.status, 1);
assert.match(res.stderr, /Comment body is required/);
} finally {
fs.unlinkSync(cfg);
}
});
test('issue comment rejects a whitespace-only body before any network call', () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}.json`);
fs.writeFileSync(cfg, JSON.stringify({ url: 'https://forge.test', token: 'tok' }));
try {
const res = run(['issue', 'comment', '-o', 'o', '-r', 'r', '-n', '1', '-b', ' '], { STOKE_CONFIG_FILE: cfg });
assert.equal(res.status, 1);
assert.match(res.stderr, /Comment body is required/);
} finally {
fs.unlinkSync(cfg);
}
});
test('issue comment posts to the issue comments endpoint', async () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-ic.json`);
const TIMEOUT_MS = 5000;
let timer;
const result = await new Promise((resolve, reject) => {
const fail = (err) => {
clearTimeout(timer);
try { server.close(); } catch { /* already closed */ }
reject(err instanceof Error ? err : new Error(String(err)));
};
let request = null;
const server = http.createServer((req, res) => {
let data = '';
req.on('data', (c) => { data += c; });
req.on('end', () => {
request = { url: req.url, body: data };
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: 9, html_url: 'https://forge.test/issues/7#issuecomment-9' }));
});
});
timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS);
server.listen(0, '127.0.0.1', async () => {
const { port } = server.address();
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
try {
const res = await spawnAsync(
['issue', 'comment', '-o', 'o', '-r', 'r', '-n', '7', '-b', 'Confirmed.'],
{ STOKE_CONFIG_FILE: cfg },
);
clearTimeout(timer);
server.close(() => resolve({ res, request }));
} catch (err) {
fail(err);
}
});
}).finally(() => clearTimeout(timer));
try {
assert.equal(result.res.status, 0, result.res.stderr);
assert.equal(result.request.url, '/api/v1/repos/o/r/issues/7/comments');
assert.equal(JSON.parse(result.request.body).body, 'Confirmed.');
assert.match(result.res.stdout, /Comment added to #7\./);
assert.match(result.res.stdout, /URL: https:\/\/forge\.test\/issues\/7#issuecomment-9/);
} finally {
fs.unlinkSync(cfg);
}
});
test('read commands print the raw API JSON with --json', async () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-json.json`);
const payloads = {
'/api/v1/user': { login: 'bot' },
'/api/v1/user/repos': [{ full_name: 'o/r' }],
'/api/v1/repos/o/r/issues': [{ number: 7, title: 'Bug' }],
'/api/v1/repos/o/r/issues/7': { number: 7, title: 'Bug', state: 'open' },
'/api/v1/repos/o/r/pulls': [{ number: 3, title: 'Fix' }],
'/api/v1/repos/o/r/pulls/3': { number: 3, title: 'Fix', state: 'open' },
};
const commands = [
[['auth', 'status', '--json'], { login: 'bot' }],
[['repo', 'list', '--json'], [{ full_name: 'o/r' }]],
[['issue', 'list', '-o', 'o', '-r', 'r', '--json'], [{ number: 7, title: 'Bug' }]],
[['issue', 'show', '-o', 'o', '-r', 'r', '-n', '7', '--json'], { number: 7, title: 'Bug', state: 'open' }],
[['pr', 'list', '-o', 'o', '-r', 'r', '--json'], [{ number: 3, title: 'Fix' }]],
[['pr', 'show', '-o', 'o', '-r', 'r', '-n', '3', '--json'], { number: 3, title: 'Fix', state: 'open' }],
];
const TIMEOUT_MS = 5000;
let timer;
const results = await new Promise((resolve, reject) => {
const fail = (err) => {
clearTimeout(timer);
try { server.close(); } catch { /* already closed */ }
reject(err instanceof Error ? err : new Error(String(err)));
};
const server = http.createServer((req, res) => {
const pathname = new URL(req.url, 'http://localhost').pathname;
const payload = payloads[pathname];
if (!payload) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ message: `no mock for ${pathname}` }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload));
});
timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS);
server.listen(0, '127.0.0.1', async () => {
const { port } = server.address();
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
try {
const out = [];
for (const [args] of commands) {
out.push(await spawnAsync(args, { STOKE_CONFIG_FILE: cfg }));
}
clearTimeout(timer);
server.close(() => resolve(out));
} catch (err) {
fail(err);
}
});
}).finally(() => clearTimeout(timer));
try {
results.forEach((res, i) => {
const [args, expected] = commands[i];
assert.equal(res.status, 0, `${args.join(' ')}: ${res.stderr}`);
assert.deepEqual(JSON.parse(res.stdout), expected);
});
} finally {
fs.unlinkSync(cfg);
}
});
test('pr review --commit sends commit_id only when given', async () => {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-commit.json`);
const TIMEOUT_MS = 5000;
let timer;
const bodies = [];
const result = await new Promise((resolve, reject) => {
const fail = (err) => {
clearTimeout(timer);
try { server.close(); } catch { /* already closed */ }
reject(err instanceof Error ? err : new Error(String(err)));
};
const server = http.createServer((req, res) => {
let data = '';
req.on('data', (c) => { data += c; });
req.on('end', () => {
bodies.push(JSON.parse(data));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: 42 }));
});
});
timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS);
server.listen(0, '127.0.0.1', async () => {
const { port } = server.address();
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
try {
const withCommit = await spawnAsync(
['pr', 'review', '-o', 'o', '-r', 'r', '-n', '7', '--event', 'approve', '--commit', 'abc123'],
{ STOKE_CONFIG_FILE: cfg },
);
const withoutCommit = await spawnAsync(
['pr', 'review', '-o', 'o', '-r', 'r', '-n', '7', '--event', 'approve'],
{ STOKE_CONFIG_FILE: cfg },
);
clearTimeout(timer);
server.close(() => resolve({ withCommit, withoutCommit }));
} catch (err) {
fail(err);
}
});
}).finally(() => clearTimeout(timer));
try {
assert.equal(result.withCommit.status, 0, result.withCommit.stderr);
assert.equal(result.withoutCommit.status, 0, result.withoutCommit.stderr);
assert.equal(bodies[0].commit_id, 'abc123');
assert.ok(!('commit_id' in bodies[1]));
} finally {
fs.unlinkSync(cfg);
}
});