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, /Revoked token/); assert.doesNotMatch(logout.stdout, /Password for/); 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(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 /); }); 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 /); assert.match(create.stdout, /--asset-name /); const upload = run(['release', 'upload', '--help']); assert.equal(upload.status, 0, upload.stderr); assert.match(upload.stdout, /--tag /); assert.match(upload.stdout, /--asset /); assert.match(upload.stdout, /--asset-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); } });