@rsktash/beads-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/.github/workflows/publish.yml +28 -0
  2. package/app/protocol.js +216 -0
  3. package/bin/bdui +19 -0
  4. package/client/index.html +12 -0
  5. package/client/postcss.config.js +11 -0
  6. package/client/src/App.tsx +35 -0
  7. package/client/src/components/IssueCard.tsx +73 -0
  8. package/client/src/components/Layout.tsx +175 -0
  9. package/client/src/components/Markdown.tsx +77 -0
  10. package/client/src/components/PriorityBadge.tsx +26 -0
  11. package/client/src/components/SearchDialog.tsx +137 -0
  12. package/client/src/components/SectionEditor.tsx +212 -0
  13. package/client/src/components/StatusBadge.tsx +64 -0
  14. package/client/src/components/TypeBadge.tsx +26 -0
  15. package/client/src/hooks/use-mutation.ts +55 -0
  16. package/client/src/hooks/use-search.ts +19 -0
  17. package/client/src/hooks/use-subscription.ts +187 -0
  18. package/client/src/index.css +133 -0
  19. package/client/src/lib/avatar.ts +17 -0
  20. package/client/src/lib/types.ts +115 -0
  21. package/client/src/lib/ws-client.ts +214 -0
  22. package/client/src/lib/ws-context.tsx +28 -0
  23. package/client/src/main.tsx +10 -0
  24. package/client/src/views/Board.tsx +200 -0
  25. package/client/src/views/Detail.tsx +398 -0
  26. package/client/src/views/List.tsx +461 -0
  27. package/client/tailwind.config.ts +68 -0
  28. package/client/tsconfig.json +16 -0
  29. package/client/vite.config.ts +20 -0
  30. package/package.json +43 -0
  31. package/server/app.js +120 -0
  32. package/server/app.test.js +30 -0
  33. package/server/bd.js +227 -0
  34. package/server/bd.test.js +194 -0
  35. package/server/cli/cli.test.js +207 -0
  36. package/server/cli/commands.integration.test.js +148 -0
  37. package/server/cli/commands.js +285 -0
  38. package/server/cli/commands.unit.test.js +408 -0
  39. package/server/cli/daemon.js +340 -0
  40. package/server/cli/daemon.test.js +31 -0
  41. package/server/cli/index.js +135 -0
  42. package/server/cli/open.js +178 -0
  43. package/server/cli/open.test.js +26 -0
  44. package/server/cli/usage.js +27 -0
  45. package/server/config.js +36 -0
  46. package/server/db.js +154 -0
  47. package/server/db.test.js +169 -0
  48. package/server/dolt-pool.js +257 -0
  49. package/server/dolt-queries.js +646 -0
  50. package/server/index.js +97 -0
  51. package/server/list-adapters.js +395 -0
  52. package/server/list-adapters.test.js +208 -0
  53. package/server/logging.js +23 -0
  54. package/server/registry-watcher.js +200 -0
  55. package/server/subscriptions.js +299 -0
  56. package/server/subscriptions.test.js +128 -0
  57. package/server/validators.js +124 -0
  58. package/server/watcher.js +139 -0
  59. package/server/watcher.test.js +120 -0
  60. package/server/ws.comments.test.js +262 -0
  61. package/server/ws.delete.test.js +119 -0
  62. package/server/ws.js +1309 -0
  63. package/server/ws.labels.test.js +95 -0
  64. package/server/ws.list-refresh.coalesce.test.js +95 -0
  65. package/server/ws.list-subscriptions.test.js +403 -0
  66. package/server/ws.mutation-window.test.js +147 -0
  67. package/server/ws.mutations.test.js +389 -0
  68. package/server/ws.test.js +52 -0
package/server/app.js ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * @import { Express, Request, Response } from 'express'
3
+ */
4
+ import express from 'express';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import {
8
+ getAvailableWorkspaces,
9
+ registerWorkspace
10
+ } from './registry-watcher.js';
11
+
12
+ /**
13
+ * Create and configure the Express application.
14
+ *
15
+ * @param {{ host: string, port: number, app_dir: string, root_dir: string }} config - Server configuration.
16
+ * @returns {Express} Configured Express app instance.
17
+ */
18
+ export function createApp(config) {
19
+ const app = express();
20
+
21
+ // Basic hardening and config
22
+ app.disable('x-powered-by');
23
+
24
+ // Health endpoint
25
+ /**
26
+ * @param {Request} _req
27
+ * @param {Response} res
28
+ */
29
+ app.get('/healthz', (_req, res) => {
30
+ res.type('application/json');
31
+ res.status(200).send({ ok: true });
32
+ });
33
+
34
+ // Enable JSON body parsing for API endpoints
35
+ app.use(express.json());
36
+
37
+ // Register workspace endpoint - allows CLI to register workspaces dynamically
38
+ // when the server is already running
39
+ /**
40
+ * @param {Request} req
41
+ * @param {Response} res
42
+ */
43
+ app.post('/api/register-workspace', (req, res) => {
44
+ const { path: workspace_path, database } = req.body || {};
45
+ if (!workspace_path || typeof workspace_path !== 'string') {
46
+ res.status(400).json({ ok: false, error: 'Missing or invalid path' });
47
+ return;
48
+ }
49
+ if (!database || typeof database !== 'string') {
50
+ res.status(400).json({ ok: false, error: 'Missing or invalid database' });
51
+ return;
52
+ }
53
+ registerWorkspace({ path: workspace_path, database });
54
+ res.status(200).json({ ok: true, registered: workspace_path });
55
+ });
56
+
57
+ // List all known workspaces (file-based registry + in-memory)
58
+ app.get('/api/workspaces', (_req, res) => {
59
+ const workspaces = getAvailableWorkspaces();
60
+ res.status(200).json({ ok: true, workspaces });
61
+ });
62
+
63
+ if (
64
+ !fs.statSync(path.resolve(config.app_dir, 'main.bundle.js'), {
65
+ throwIfNoEntry: false
66
+ })
67
+ ) {
68
+ /**
69
+ * On-demand bundle for the browser using esbuild.
70
+ *
71
+ * @param {Request} _req
72
+ * @param {Response} res
73
+ */
74
+ app.get('/main.bundle.js', async (_req, res) => {
75
+ try {
76
+ const esbuild = await import('esbuild');
77
+ const entry = path.join(config.app_dir, 'main.js');
78
+ const result = await esbuild.build({
79
+ entryPoints: [entry],
80
+ bundle: true,
81
+ format: 'esm',
82
+ platform: 'browser',
83
+ target: 'es2020',
84
+ sourcemap: 'inline',
85
+ minify: false,
86
+ write: false
87
+ });
88
+ const out = result.outputFiles && result.outputFiles[0];
89
+ if (!out) {
90
+ res.status(500).type('text/plain').send('Bundle failed: no output');
91
+ return;
92
+ }
93
+ res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
94
+ res.setHeader('Cache-Control', 'no-store');
95
+ res.send(out.text);
96
+ } catch (err) {
97
+ res
98
+ .status(500)
99
+ .type('text/plain')
100
+ .send('Bundle error: ' + (err && /** @type {any} */ (err).message));
101
+ }
102
+ });
103
+ }
104
+
105
+ // Static assets — prefer BEADS_UI_STATIC (Vite build output), fall back to app_dir
106
+ const staticDir = process.env.BEADS_UI_STATIC || config.app_dir;
107
+ app.use(express.static(staticDir));
108
+
109
+ // Root serves index.html explicitly (even if static would catch it)
110
+ /**
111
+ * @param {Request} _req
112
+ * @param {Response} res
113
+ */
114
+ app.get('/', (_req, res) => {
115
+ const index_path = path.join(staticDir, 'index.html');
116
+ res.sendFile(index_path);
117
+ });
118
+
119
+ return app;
120
+ }
@@ -0,0 +1,30 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { describe, expect, test } from 'vitest';
4
+ import { createApp } from './app.js';
5
+ import { getConfig } from './config.js';
6
+
7
+ /**
8
+ * Narrow to function type for basic checks.
9
+ *
10
+ * @param {unknown} value
11
+ * @returns {value is Function}
12
+ */
13
+ function isFunction(value) {
14
+ return typeof value === 'function';
15
+ }
16
+
17
+ describe('server app wiring (no listen)', () => {
18
+ test('createApp returns an express-like app', () => {
19
+ const config = getConfig();
20
+ const app = createApp(config);
21
+ expect(isFunction(app.get)).toBe(true);
22
+ expect(isFunction(app.use)).toBe(true);
23
+ });
24
+
25
+ test('index.html exists in configured app_dir', () => {
26
+ const config = getConfig();
27
+ const index_path = path.join(config.app_dir, 'index.html');
28
+ expect(fs.existsSync(index_path)).toBe(true);
29
+ });
30
+ });
package/server/bd.js ADDED
@@ -0,0 +1,227 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { resolveDbPath } from './db.js';
3
+ import { debug } from './logging.js';
4
+
5
+ const log = debug('bd');
6
+ /** @type {Promise<void>} */
7
+ let bd_run_queue = Promise.resolve();
8
+
9
+ /**
10
+ * Get the git user name from git config.
11
+ *
12
+ * @param {{ cwd?: string }} [options]
13
+ * @returns {Promise<string>}
14
+ */
15
+ export async function getGitUserName(options = {}) {
16
+ return new Promise((resolve) => {
17
+ const child = spawn('git', ['config', 'user.name'], {
18
+ cwd: options.cwd || process.cwd(),
19
+ shell: false,
20
+ windowsHide: true
21
+ });
22
+
23
+ /** @type {string[]} */
24
+ const chunks = [];
25
+
26
+ if (child.stdout) {
27
+ child.stdout.setEncoding('utf8');
28
+ child.stdout.on('data', (chunk) => chunks.push(String(chunk)));
29
+ }
30
+
31
+ child.on('error', () => resolve(''));
32
+ child.on('close', (code) => {
33
+ if (code !== 0) {
34
+ resolve('');
35
+ return;
36
+ }
37
+ resolve(chunks.join('').trim());
38
+ });
39
+ });
40
+ }
41
+
42
+ /**
43
+ * Resolve the bd executable path.
44
+ *
45
+ * @returns {string}
46
+ */
47
+ export function getBdBin() {
48
+ const env_value = process.env.BD_BIN;
49
+ if (env_value && env_value.length > 0) {
50
+ return env_value;
51
+ }
52
+ return 'bd';
53
+ }
54
+
55
+ /**
56
+ * Run the `bd` CLI with provided arguments.
57
+ * Shell is not used to avoid injection; args must be pre-split.
58
+ *
59
+ * @param {string[]} args - Arguments to pass (e.g., ["list", "--json"]).
60
+ * @param {{ cwd?: string, env?: Record<string, string | undefined>, timeout_ms?: number }} [options]
61
+ * @returns {Promise<{ code: number, stdout: string, stderr: string }>}
62
+ */
63
+ export function runBd(args, options = {}) {
64
+ return withBdRunQueue(async () => runBdUnlocked(args, options));
65
+ }
66
+
67
+ /**
68
+ * Run the `bd` CLI with provided arguments without queueing.
69
+ *
70
+ * @param {string[]} args
71
+ * @param {{ cwd?: string, env?: Record<string, string | undefined>, timeout_ms?: number }} [options]
72
+ * @returns {Promise<{ code: number, stdout: string, stderr: string }>}
73
+ */
74
+ function runBdUnlocked(args, options = {}) {
75
+ const bin = getBdBin();
76
+
77
+ // Set BEADS_DB only when the workspace has a local SQLite DB.
78
+ // Do not force BEADS_DB from global fallback paths; this can override
79
+ // backend autodetection in non-SQLite workspaces (for example Dolt).
80
+ const db_path = resolveDbPath({
81
+ cwd: options.cwd || process.cwd(),
82
+ env: options.env || process.env
83
+ });
84
+ const env_with_db = { ...(options.env || process.env) };
85
+ if (db_path.source === 'nearest' && db_path.exists) {
86
+ env_with_db.BEADS_DB = db_path.path;
87
+ }
88
+
89
+ const spawn_opts = {
90
+ cwd: options.cwd || process.cwd(),
91
+ env: env_with_db,
92
+ shell: false,
93
+ windowsHide: true
94
+ };
95
+
96
+ /** @type {string[]} */
97
+ const final_args = buildBdArgs(args);
98
+
99
+ return new Promise((resolve) => {
100
+ const child = spawn(bin, final_args, spawn_opts);
101
+
102
+ /** @type {string[]} */
103
+ const out_chunks = [];
104
+ /** @type {string[]} */
105
+ const err_chunks = [];
106
+
107
+ if (child.stdout) {
108
+ child.stdout.setEncoding('utf8');
109
+ child.stdout.on('data', (chunk) => {
110
+ out_chunks.push(String(chunk));
111
+ });
112
+ }
113
+ if (child.stderr) {
114
+ child.stderr.setEncoding('utf8');
115
+ child.stderr.on('data', (chunk) => {
116
+ err_chunks.push(String(chunk));
117
+ });
118
+ }
119
+
120
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
121
+ let timer;
122
+ if (options.timeout_ms && options.timeout_ms > 0) {
123
+ timer = setTimeout(() => {
124
+ child.kill('SIGKILL');
125
+ }, options.timeout_ms);
126
+ timer.unref?.();
127
+ }
128
+
129
+ /**
130
+ * @param {number | string | null} code
131
+ */
132
+ const finish = (code) => {
133
+ if (timer) {
134
+ clearTimeout(timer);
135
+ }
136
+ resolve({
137
+ code: Number(code || 0),
138
+ stdout: out_chunks.join(''),
139
+ stderr: err_chunks.join('')
140
+ });
141
+ };
142
+
143
+ child.on('error', (err) => {
144
+ // Treat spawn error as an immediate non-zero exit; log for diagnostics.
145
+ log('spawn error running %s %o', bin, err);
146
+ finish(127);
147
+ });
148
+ child.on('close', (code) => {
149
+ finish(code);
150
+ });
151
+ });
152
+ }
153
+
154
+ /**
155
+ * Build final bd CLI arguments.
156
+ * bdui defaults to sandbox mode to avoid sync/autopush overhead on interactive
157
+ * UI requests. Set `BDUI_BD_SANDBOX=0` (or "false") to opt out.
158
+ *
159
+ * @param {string[]} args
160
+ * @returns {string[]}
161
+ */
162
+ function buildBdArgs(args) {
163
+ const arg_set = new Set(args);
164
+ const raw_sandbox = String(process.env.BDUI_BD_SANDBOX || '').toLowerCase();
165
+ const sandbox_disabled = raw_sandbox === '0' || raw_sandbox === 'false';
166
+ const should_prepend_sandbox = !sandbox_disabled && !arg_set.has('--sandbox');
167
+
168
+ if (!should_prepend_sandbox) {
169
+ return args.slice();
170
+ }
171
+
172
+ return ['--sandbox', ...args];
173
+ }
174
+
175
+ /**
176
+ * Serialize `bd` invocations.
177
+ * Dolt embedded mode can crash when multiple `bd` processes run concurrently
178
+ * against the same workspace.
179
+ *
180
+ * @template T
181
+ * @param {() => Promise<T>} operation
182
+ * @returns {Promise<T>}
183
+ */
184
+ async function withBdRunQueue(operation) {
185
+ const previous = bd_run_queue;
186
+ /** @type {() => void} */
187
+ let release = () => {};
188
+ bd_run_queue = new Promise((resolve) => {
189
+ release = resolve;
190
+ });
191
+
192
+ await previous.catch(() => {});
193
+ try {
194
+ return await operation();
195
+ } finally {
196
+ release();
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Run `bd` and parse JSON from stdout if exit code is 0.
202
+ *
203
+ * @param {string[]} args - Must include flags that cause JSON to be printed (e.g., `--json`).
204
+ * @param {{ cwd?: string, env?: Record<string, string | undefined>, timeout_ms?: number }} [options]
205
+ * @returns {Promise<{ code: number, stdoutJson?: unknown, stderr?: string }>}
206
+ */
207
+ export async function runBdJson(args, options = {}) {
208
+ const result = await runBd(args, options);
209
+ if (result.code !== 0) {
210
+ log(
211
+ 'bd exited with code %d (args=%o) stderr=%s',
212
+ result.code,
213
+ args,
214
+ result.stderr
215
+ );
216
+ return { code: result.code, stderr: result.stderr };
217
+ }
218
+ /** @type {unknown} */
219
+ let parsed;
220
+ try {
221
+ parsed = JSON.parse(result.stdout || 'null');
222
+ } catch (err) {
223
+ log('bd returned invalid JSON (args=%o): %o', args, err);
224
+ return { code: 0, stderr: 'Invalid JSON from bd' };
225
+ }
226
+ return { code: 0, stdoutJson: parsed };
227
+ }
@@ -0,0 +1,194 @@
1
+ import { spawn as spawnMock } from 'node:child_process';
2
+ import { EventEmitter } from 'node:events';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { PassThrough } from 'node:stream';
7
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
8
+ import { getBdBin, getGitUserName, runBd, runBdJson } from './bd.js';
9
+
10
+ // Mock child_process.spawn before importing the module under test
11
+ vi.mock('node:child_process', () => ({ spawn: vi.fn() }));
12
+
13
+ /**
14
+ * @param {string} stdoutText
15
+ * @param {string} stderrText
16
+ * @param {number} code
17
+ */
18
+ function makeFakeProc(stdoutText, stderrText, code) {
19
+ const cp = /** @type {any} */ (new EventEmitter());
20
+ const out = new PassThrough();
21
+ const err = new PassThrough();
22
+ cp.stdout = out;
23
+ cp.stderr = err;
24
+ // Simulate async emission
25
+ setTimeout(() => {
26
+ if (stdoutText) {
27
+ out.write(stdoutText);
28
+ }
29
+ out.end();
30
+ if (stderrText) {
31
+ err.write(stderrText);
32
+ }
33
+ err.end();
34
+ cp.emit('close', code);
35
+ }, 0);
36
+ return cp;
37
+ }
38
+
39
+ const mockedSpawn = /** @type {import('vitest').Mock} */ (spawnMock);
40
+ /** @type {string[]} */
41
+ const temp_dirs = [];
42
+
43
+ function make_temp_dir() {
44
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bdui-bd-'));
45
+ temp_dirs.push(dir);
46
+ return dir;
47
+ }
48
+
49
+ beforeEach(() => {
50
+ mockedSpawn.mockReset();
51
+ });
52
+
53
+ afterEach(() => {
54
+ for (const dir of temp_dirs.splice(0)) {
55
+ try {
56
+ fs.rmSync(dir, { recursive: true, force: true });
57
+ } catch {
58
+ // ignore cleanup errors
59
+ }
60
+ }
61
+ });
62
+
63
+ describe('getBdBin', () => {
64
+ test('returns env BD_BIN when set', () => {
65
+ const prev = process.env.BD_BIN;
66
+ process.env.BD_BIN = '/custom/bd';
67
+ expect(getBdBin()).toBe('/custom/bd');
68
+ if (prev) {
69
+ process.env.BD_BIN = prev;
70
+ } else {
71
+ delete process.env.BD_BIN;
72
+ }
73
+ });
74
+ });
75
+
76
+ describe('runBd', () => {
77
+ test('prepends --sandbox by default', async () => {
78
+ mockedSpawn.mockReturnValueOnce(makeFakeProc('ok', '', 0));
79
+ await runBd(['list', '--json']);
80
+
81
+ const args = mockedSpawn.mock.calls[0][1];
82
+ expect(args[0]).toBe('--sandbox');
83
+ expect(args.slice(1)).toEqual(['list', '--json']);
84
+ });
85
+
86
+ test('does not duplicate --sandbox when caller already provides it', async () => {
87
+ mockedSpawn.mockReturnValueOnce(makeFakeProc('ok', '', 0));
88
+ await runBd(['--sandbox', 'list', '--json']);
89
+
90
+ const args = mockedSpawn.mock.calls[0][1];
91
+ expect(args).toEqual(['--sandbox', 'list', '--json']);
92
+ });
93
+
94
+ test('allows disabling default sandbox via BDUI_BD_SANDBOX', async () => {
95
+ const prev = process.env.BDUI_BD_SANDBOX;
96
+ process.env.BDUI_BD_SANDBOX = '0';
97
+ mockedSpawn.mockReturnValueOnce(makeFakeProc('ok', '', 0));
98
+
99
+ await runBd(['list', '--json']);
100
+
101
+ const args = mockedSpawn.mock.calls[0][1];
102
+ expect(args).toEqual(['list', '--json']);
103
+
104
+ if (prev === undefined) {
105
+ delete process.env.BDUI_BD_SANDBOX;
106
+ } else {
107
+ process.env.BDUI_BD_SANDBOX = prev;
108
+ }
109
+ });
110
+
111
+ test('returns stdout/stderr and exit code', async () => {
112
+ mockedSpawn.mockReturnValueOnce(makeFakeProc('ok', '', 0));
113
+ const res = await runBd(['--version']);
114
+ expect(res.code).toBe(0);
115
+ expect(res.stdout).toContain('ok');
116
+ });
117
+
118
+ test('non-zero exit propagates code and stderr', async () => {
119
+ mockedSpawn.mockReturnValueOnce(makeFakeProc('', 'boom', 1));
120
+ const res = await runBd(['list']);
121
+ expect(res.code).toBe(1);
122
+ expect(res.stderr).toContain('boom');
123
+ });
124
+
125
+ test('sets BEADS_DB for workspace-local SQLite db', async () => {
126
+ const root = make_temp_dir();
127
+ const beads_dir = path.join(root, '.beads');
128
+ fs.mkdirSync(beads_dir, { recursive: true });
129
+ const workspace_db = path.join(beads_dir, 'ui.db');
130
+ fs.writeFileSync(workspace_db, '');
131
+
132
+ mockedSpawn.mockReturnValueOnce(makeFakeProc('ok', '', 0));
133
+ await runBd(['list'], { cwd: root, env: {} });
134
+
135
+ const options = mockedSpawn.mock.calls[0][2];
136
+ expect(options.env.BEADS_DB).toBe(workspace_db);
137
+ });
138
+
139
+ test('does not force BEADS_DB when workspace has no local SQLite db', async () => {
140
+ const root = make_temp_dir();
141
+
142
+ mockedSpawn.mockReturnValueOnce(makeFakeProc('ok', '', 0));
143
+ await runBd(['list'], { cwd: root, env: {} });
144
+
145
+ const options = mockedSpawn.mock.calls[0][2];
146
+ expect(options.env.BEADS_DB).toBeUndefined();
147
+ });
148
+
149
+ test('preserves explicit BEADS_DB from caller env', async () => {
150
+ mockedSpawn.mockReturnValueOnce(makeFakeProc('ok', '', 0));
151
+ await runBd(['list'], { env: { BEADS_DB: '/custom/workspace.db' } });
152
+
153
+ const options = mockedSpawn.mock.calls[0][2];
154
+ expect(options.env.BEADS_DB).toBe('/custom/workspace.db');
155
+ });
156
+ });
157
+
158
+ describe('runBdJson', () => {
159
+ test('parses valid JSON output', async () => {
160
+ const json = JSON.stringify([{ id: 'UI-1' }]);
161
+ mockedSpawn.mockReturnValueOnce(makeFakeProc(json, '', 0));
162
+ const res = await runBdJson(['list', '--json']);
163
+ expect(res.code).toBe(0);
164
+ expect(Array.isArray(res.stdoutJson)).toBe(true);
165
+ });
166
+
167
+ test('invalid JSON yields stderr message with code 0', async () => {
168
+ mockedSpawn.mockReturnValueOnce(makeFakeProc('not-json', '', 0));
169
+ const res = await runBdJson(['list', '--json']);
170
+ expect(res.code).toBe(0);
171
+ expect(res.stderr).toContain('Invalid JSON');
172
+ });
173
+
174
+ test('non-zero exit returns code and stderr', async () => {
175
+ mockedSpawn.mockReturnValueOnce(makeFakeProc('', 'oops', 2));
176
+ const res = await runBdJson(['list', '--json']);
177
+ expect(res.code).toBe(2);
178
+ expect(res.stderr).toContain('oops');
179
+ });
180
+ });
181
+
182
+ describe('getGitUserName', () => {
183
+ test('returns git user name on success', async () => {
184
+ mockedSpawn.mockReturnValueOnce(makeFakeProc('Alice Smith\n', '', 0));
185
+ const name = await getGitUserName();
186
+ expect(name).toBe('Alice Smith');
187
+ });
188
+
189
+ test('returns empty string on failure', async () => {
190
+ mockedSpawn.mockReturnValueOnce(makeFakeProc('', 'error', 1));
191
+ const name = await getGitUserName();
192
+ expect(name).toBe('');
193
+ });
194
+ });