@humanbased/crosscheck 1.5.0 → 1.6.0-beta.13
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.
- package/dist/__tests__/board.test.js +527 -20
- package/dist/__tests__/board.test.js.map +1 -1
- package/dist/__tests__/lock-ownership.test.d.ts +2 -0
- package/dist/__tests__/lock-ownership.test.d.ts.map +1 -0
- package/dist/__tests__/lock-ownership.test.js +167 -0
- package/dist/__tests__/lock-ownership.test.js.map +1 -0
- package/dist/__tests__/vendor-error-summary.test.d.ts +2 -0
- package/dist/__tests__/vendor-error-summary.test.d.ts.map +1 -0
- package/dist/__tests__/vendor-error-summary.test.js +74 -0
- package/dist/__tests__/vendor-error-summary.test.js.map +1 -0
- package/dist/cli.js +2 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/review.d.ts +3 -1
- package/dist/commands/review.d.ts.map +1 -1
- package/dist/commands/review.js +88 -2
- package/dist/commands/review.js.map +1 -1
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +22 -5
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/watch.d.ts.map +1 -1
- package/dist/commands/watch.js +27 -23
- package/dist/commands/watch.js.map +1 -1
- package/dist/github/review-status.d.ts +33 -0
- package/dist/github/review-status.d.ts.map +1 -1
- package/dist/github/review-status.js +94 -9
- package/dist/github/review-status.js.map +1 -1
- package/dist/lib/board.d.ts +59 -4
- package/dist/lib/board.d.ts.map +1 -1
- package/dist/lib/board.js +376 -64
- package/dist/lib/board.js.map +1 -1
- package/dist/lib/pr-lock.d.ts +37 -1
- package/dist/lib/pr-lock.d.ts.map +1 -1
- package/dist/lib/pr-lock.js +176 -29
- package/dist/lib/pr-lock.js.map +1 -1
- package/dist/lib/pr-workflow-state.d.ts +2 -0
- package/dist/lib/pr-workflow-state.d.ts.map +1 -1
- package/dist/lib/pr-workflow-state.js +15 -0
- package/dist/lib/pr-workflow-state.js.map +1 -1
- package/dist/lib/tips.d.ts.map +1 -1
- package/dist/lib/tips.js +1 -0
- package/dist/lib/tips.js.map +1 -1
- package/dist/lib/vendor-error-summary.d.ts +19 -0
- package/dist/lib/vendor-error-summary.d.ts.map +1 -0
- package/dist/lib/vendor-error-summary.js +95 -0
- package/dist/lib/vendor-error-summary.js.map +1 -0
- package/dist/reviewers/claude.d.ts.map +1 -1
- package/dist/reviewers/claude.js +2 -1
- package/dist/reviewers/claude.js.map +1 -1
- package/dist/reviewers/codex.d.ts.map +1 -1
- package/dist/reviewers/codex.js +2 -1
- package/dist/reviewers/codex.js.map +1 -1
- package/get-started.md +18 -0
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import {
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { EventEmitter } from 'events';
|
|
3
|
+
import { PRBoard, fmtTokens, pageKeyAction, isNoticeLine, distribute, fmtUptime } from '../lib/board.js';
|
|
3
4
|
describe('fmtTokens', () => {
|
|
4
5
|
it('returns empty string for undefined', () => {
|
|
5
6
|
expect(fmtTokens(undefined)).toBe('');
|
|
@@ -85,26 +86,34 @@ describe('PRBoard — TTY workspace retention', () => {
|
|
|
85
86
|
const slots = () => board.slots;
|
|
86
87
|
const invokeRender = () => board.render();
|
|
87
88
|
const invokeFolded = (key) => board.renderPRSlotFolded(slots().get(key));
|
|
89
|
+
const superseded = (key) => slots().get(key)?.superseded === true;
|
|
90
|
+
// The PR rows only. Layout is config │ stats │ workspace │ footer, separated by
|
|
91
|
+
// rules, so the third block is the workspace. Assertions about which PRs are on
|
|
92
|
+
// screen scope to this: the stats panel names verdicts too (outcome shares), so
|
|
93
|
+
// searching the whole frame for "BLOCK" no longer answers "is that row shown?".
|
|
94
|
+
const workspaceOf = (output) => {
|
|
95
|
+
const blocks = output.split(/^─+$/m);
|
|
96
|
+
return blocks[2] ?? '';
|
|
97
|
+
};
|
|
88
98
|
it('keeps completed slot in the workspace (no auto-clear)', () => {
|
|
89
99
|
board.addPR('k1', 1, 'a/b', 'main');
|
|
90
100
|
board.completePR('k1', { elapsedMs: 1000, url: 'https://github.com/a/b/pull/1' });
|
|
91
101
|
expect(slots().size).toBe(1);
|
|
92
102
|
expect(slots().has('k1')).toBe(true);
|
|
93
103
|
});
|
|
94
|
-
|
|
104
|
+
// The workspace is the session record now: nothing is dropped on a count cap,
|
|
105
|
+
// because anything the live page cannot show is reachable on a history page.
|
|
106
|
+
it('keeps every completed PR in history instead of evicting on a count cap', () => {
|
|
95
107
|
for (let i = 0; i < 30; i++) {
|
|
96
108
|
board.addPR(`k${i}`, i, 'a/b', `branch-${i}`);
|
|
97
109
|
board.completePR(`k${i}`, { elapsedMs: 1000, url: `https://github.com/a/b/pull/${i}` });
|
|
98
110
|
}
|
|
99
111
|
invokeRender();
|
|
100
|
-
expect(slots().size).toBe(
|
|
101
|
-
|
|
102
|
-
for (let i = 0; i < 5; i++)
|
|
103
|
-
expect(slots().has(`k${i}`)).toBe(false);
|
|
104
|
-
for (let i = 5; i < 30; i++)
|
|
112
|
+
expect(slots().size).toBe(30);
|
|
113
|
+
for (let i = 0; i < 30; i++)
|
|
105
114
|
expect(slots().has(`k${i}`)).toBe(true);
|
|
106
115
|
});
|
|
107
|
-
it('
|
|
116
|
+
it('keeps active slots on the live page however much history is behind them', () => {
|
|
108
117
|
for (let i = 0; i < 24; i++) {
|
|
109
118
|
board.addPR(`done-${i}`, i, 'a/b', `branch-${i}`);
|
|
110
119
|
board.completePR(`done-${i}`, { elapsedMs: 1000, url: `url-${i}` });
|
|
@@ -112,11 +121,11 @@ describe('PRBoard — TTY workspace retention', () => {
|
|
|
112
121
|
for (let i = 0; i < 5; i++) {
|
|
113
122
|
board.addPR(`active-${i}`, 100 + i, 'a/b', `active-${i}`);
|
|
114
123
|
}
|
|
124
|
+
const output = stripAnsi(invokeRender());
|
|
115
125
|
expect(slots().size).toBe(29);
|
|
116
|
-
invokeRender();
|
|
117
|
-
expect(slots().size).toBe(25);
|
|
118
126
|
for (let i = 0; i < 5; i++) {
|
|
119
127
|
expect(slots().has(`active-${i}`)).toBe(true);
|
|
128
|
+
expect(output).toContain(`active-${i}`);
|
|
120
129
|
}
|
|
121
130
|
});
|
|
122
131
|
it('renders a folded line with verdict, fix count, recheck and url', () => {
|
|
@@ -153,7 +162,7 @@ describe('PRBoard — TTY workspace retention', () => {
|
|
|
153
162
|
});
|
|
154
163
|
expect(stripAnsi(invokeRender())).toContain('skipped · generated');
|
|
155
164
|
});
|
|
156
|
-
it('
|
|
165
|
+
it('supersedes the prior-round completed slot when round 2 starts for the same PR', () => {
|
|
157
166
|
// Round 1 — BLOCK, fix skipped, recheck skipped (the stale slot the user saw)
|
|
158
167
|
board.addPR('k1@sha1', 214, 'owner/repo', 'fix/branch', 1);
|
|
159
168
|
board.updatePR('k1@sha1', { verdict: 'BLOCK', commentCount: 1, fixCount: 0 });
|
|
@@ -161,15 +170,16 @@ describe('PRBoard — TTY workspace retention', () => {
|
|
|
161
170
|
expect(slots().has('k1@sha1')).toBe(true);
|
|
162
171
|
// Round 2 — new SHA push: board must evict round 1 and add round 2
|
|
163
172
|
board.addPR('k1@sha2', 214, 'owner/repo', 'fix/branch', 2);
|
|
164
|
-
expect(slots().has('k1@sha1')).toBe(
|
|
173
|
+
expect(slots().has('k1@sha1')).toBe(true); // prior round kept in history
|
|
174
|
+
expect(superseded('k1@sha1')).toBe(true); // but off the live page
|
|
165
175
|
expect(slots().has('k1@sha2')).toBe(true); // new round present
|
|
166
176
|
board.updatePR('k1@sha2', { recheckVerdict: 'APPROVE' });
|
|
167
177
|
board.completePR('k1@sha2', { elapsedMs: 362_000, url: 'https://github.com/owner/repo/pull/214' });
|
|
168
|
-
const
|
|
169
|
-
expect(
|
|
170
|
-
expect(
|
|
178
|
+
const workspace = workspaceOf(stripAnsi(invokeRender()));
|
|
179
|
+
expect(workspace).not.toContain('BLOCK');
|
|
180
|
+
expect(workspace).toContain('APPROVE');
|
|
171
181
|
});
|
|
172
|
-
it('does not
|
|
182
|
+
it('does not supersede active slots when round 2 starts', () => {
|
|
173
183
|
// Active round 1 for a different PR — must not be touched
|
|
174
184
|
board.addPR('other@sha', 99, 'owner/repo', 'other-branch', 1);
|
|
175
185
|
// Completed round 1 for PR 214
|
|
@@ -177,7 +187,8 @@ describe('PRBoard — TTY workspace retention', () => {
|
|
|
177
187
|
board.completePR('k1@sha1', { elapsedMs: 1_000, url: 'u' });
|
|
178
188
|
board.addPR('k1@sha2', 214, 'owner/repo', 'fix/branch', 2);
|
|
179
189
|
expect(slots().has('other@sha')).toBe(true); // untouched
|
|
180
|
-
expect(
|
|
190
|
+
expect(superseded('other@sha')).toBe(false);
|
|
191
|
+
expect(superseded('k1@sha1')).toBe(true); // prior round hidden, not dropped
|
|
181
192
|
expect(slots().has('k1@sha2')).toBe(true);
|
|
182
193
|
});
|
|
183
194
|
it('orders sections top-to-bottom: config → stats → PR workspace', () => {
|
|
@@ -272,12 +283,14 @@ describe('PRBoard — viewport height fitting', () => {
|
|
|
272
283
|
expect(output).not.toContain('issues');
|
|
273
284
|
expect(countRows(invokeRender(), 100)).toBeLessThanOrEqual(11);
|
|
274
285
|
});
|
|
275
|
-
it('
|
|
286
|
+
it('moves overflow onto history pages instead of dropping it when compact still overflows', () => {
|
|
276
287
|
setViewport(12, 100);
|
|
277
288
|
addCompleted(15);
|
|
278
289
|
const output = invokeRender();
|
|
279
290
|
expect(countRows(output, 100)).toBeLessThanOrEqual(11);
|
|
280
|
-
expect(slots().size).
|
|
291
|
+
expect(slots().size).toBe(15); // nothing dropped
|
|
292
|
+
expect(stripAnsi(output)).toContain('branch-14'); // newest is on the live page
|
|
293
|
+
expect(stripAnsi(output)).not.toContain('branch-0'); // oldest moved to history
|
|
281
294
|
});
|
|
282
295
|
it('truncates from the top as a last resort when active slots alone overflow', () => {
|
|
283
296
|
setViewport(8, 100);
|
|
@@ -314,6 +327,500 @@ describe('PRBoard — viewport height fitting', () => {
|
|
|
314
327
|
const residue = scrollback.filter(l => liveOnlyMarkers.some(m => l.includes(m)));
|
|
315
328
|
expect(residue).toEqual([]);
|
|
316
329
|
});
|
|
330
|
+
// failPR settles into the live block now rather than printing a static line
|
|
331
|
+
// per failure. This pins that: a long run of failures on a short viewport must
|
|
332
|
+
// stay a redraw, never scrollback. (It is an invariant guard, not a reproducer
|
|
333
|
+
// — the doubled header operators report comes from a viewport resize, which
|
|
334
|
+
// strands rows above the cursor-up clamp regardless of what wrote them.)
|
|
335
|
+
it('keeps a long run of failures in the live block, not scrollback', () => {
|
|
336
|
+
const ROWS = 14, COLS = 100;
|
|
337
|
+
setViewport(ROWS, COLS);
|
|
338
|
+
board.setTunnel('smee', 'https://smee.io/test', true);
|
|
339
|
+
invokeRedraw();
|
|
340
|
+
for (let i = 0; i < 20; i++) {
|
|
341
|
+
board.addPR(`k${i}`, 4600 + i, 'humanbased-ai/monorepo', `codex/branch-${i}`);
|
|
342
|
+
invokeRedraw();
|
|
343
|
+
board.failPR(`k${i}`, 'codex: timed out after 1200s (retried once) — PR diff may be too large');
|
|
344
|
+
invokeRedraw();
|
|
345
|
+
}
|
|
346
|
+
const { scrollback } = emulateVT(captured.join(''), ROWS, COLS);
|
|
347
|
+
const liveOnlyMarkers = ['crosscheck', 'workflow:', 'vendors:', 'PRs:', 'tunnel:'];
|
|
348
|
+
const residue = scrollback.filter(l => liveOnlyMarkers.some(m => l.includes(m)));
|
|
349
|
+
expect(residue).toEqual([]);
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
// ── History pagination ────────────────────────────────────────────────────────
|
|
353
|
+
//
|
|
354
|
+
// The live page shows what fits; everything older stays in the map and is
|
|
355
|
+
// reached by flipping pages. Nothing a session has seen leaves the board.
|
|
356
|
+
describe('PRBoard — history pagination', () => {
|
|
357
|
+
let board;
|
|
358
|
+
let originalIsTTY;
|
|
359
|
+
let originalRows;
|
|
360
|
+
let originalColumns;
|
|
361
|
+
let originalWrite;
|
|
362
|
+
beforeEach(() => {
|
|
363
|
+
originalIsTTY = process.stdout.isTTY;
|
|
364
|
+
originalRows = process.stdout.rows;
|
|
365
|
+
originalColumns = process.stdout.columns;
|
|
366
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
|
|
367
|
+
Object.defineProperty(process.stdout, 'rows', { value: 20, configurable: true });
|
|
368
|
+
Object.defineProperty(process.stdout, 'columns', { value: 120, configurable: true });
|
|
369
|
+
originalWrite = process.stdout.write.bind(process.stdout);
|
|
370
|
+
process.stdout.write = (() => true);
|
|
371
|
+
board = new PRBoard();
|
|
372
|
+
board.setConfig(baseConfig, [reviewStep]);
|
|
373
|
+
});
|
|
374
|
+
afterEach(() => {
|
|
375
|
+
board.stop();
|
|
376
|
+
process.stdout.write = originalWrite;
|
|
377
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true });
|
|
378
|
+
Object.defineProperty(process.stdout, 'rows', { value: originalRows, configurable: true });
|
|
379
|
+
Object.defineProperty(process.stdout, 'columns', { value: originalColumns, configurable: true });
|
|
380
|
+
});
|
|
381
|
+
const invokeRender = () => board.render();
|
|
382
|
+
const page = () => board.page;
|
|
383
|
+
const pageCount = () => board.pageCount;
|
|
384
|
+
// The footer is the last line — the tip line also names the page keys.
|
|
385
|
+
const footerOf = (content) => stripAnsi(content).split('\n').at(-1) ?? '';
|
|
386
|
+
const addCompleted = (n) => {
|
|
387
|
+
for (let i = 0; i < n; i++) {
|
|
388
|
+
board.addPR(`k${i}`, i, 'acme/api', `branch-${i}`);
|
|
389
|
+
board.updatePR(`k${i}`, { verdict: 'APPROVE', commentCount: 2 });
|
|
390
|
+
board.completePR(`k${i}`, { elapsedMs: 60_000, url: `https://github.com/acme/api/pull/${i}` });
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
it('reaches PRs the live page cannot show by paging back', () => {
|
|
394
|
+
addCompleted(40);
|
|
395
|
+
const live = stripAnsi(invokeRender());
|
|
396
|
+
expect(live).not.toContain('branch-0 ');
|
|
397
|
+
expect(pageCount()).toBeGreaterThan(1);
|
|
398
|
+
// Walk back to the oldest page — PR 0 has to surface somewhere along it.
|
|
399
|
+
let found = live.includes('branch-0 ');
|
|
400
|
+
while (page() < pageCount() - 1 && !found) {
|
|
401
|
+
board.pageOlder();
|
|
402
|
+
found = stripAnsi(invokeRender()).includes('branch-0 ');
|
|
403
|
+
}
|
|
404
|
+
expect(found).toBe(true);
|
|
405
|
+
});
|
|
406
|
+
it('stops at the oldest page and returns to live', () => {
|
|
407
|
+
addCompleted(40);
|
|
408
|
+
invokeRender();
|
|
409
|
+
for (let i = 0; i < 50; i++)
|
|
410
|
+
board.pageOlder();
|
|
411
|
+
expect(page()).toBe(pageCount() - 1);
|
|
412
|
+
for (let i = 0; i < 50; i++)
|
|
413
|
+
board.pageNewer();
|
|
414
|
+
expect(page()).toBe(0);
|
|
415
|
+
});
|
|
416
|
+
it('stays on a history page as new PRs arrive', () => {
|
|
417
|
+
addCompleted(40);
|
|
418
|
+
invokeRender();
|
|
419
|
+
board.pageOlder();
|
|
420
|
+
board.pageOlder();
|
|
421
|
+
const before = page();
|
|
422
|
+
board.addPR('new', 999, 'acme/api', 'branch-new');
|
|
423
|
+
invokeRender();
|
|
424
|
+
expect(page()).toBe(before);
|
|
425
|
+
expect(stripAnsi(invokeRender())).not.toContain('branch-new');
|
|
426
|
+
});
|
|
427
|
+
it('labels the live page and the history pages in the footer', () => {
|
|
428
|
+
addCompleted(40);
|
|
429
|
+
expect(stripAnsi(invokeRender())).toContain('live');
|
|
430
|
+
expect(stripAnsi(invokeRender())).toMatch(/showing \d+ of 40/);
|
|
431
|
+
board.pageOlder();
|
|
432
|
+
const older = footerOf(invokeRender());
|
|
433
|
+
expect(older).toContain('history · page 2/');
|
|
434
|
+
expect(older).toContain('←');
|
|
435
|
+
});
|
|
436
|
+
it('keeps a history page inside the viewport', () => {
|
|
437
|
+
addCompleted(60);
|
|
438
|
+
invokeRender();
|
|
439
|
+
board.pageOlder();
|
|
440
|
+
const rows = stripAnsi(invokeRender()).split('\n')
|
|
441
|
+
.reduce((sum, l) => sum + Math.max(1, Math.ceil(l.length / 120)), 0);
|
|
442
|
+
expect(rows).toBeLessThanOrEqual(19);
|
|
443
|
+
});
|
|
444
|
+
it('has one page when everything fits on the live page', () => {
|
|
445
|
+
addCompleted(2);
|
|
446
|
+
invokeRender();
|
|
447
|
+
expect(pageCount()).toBe(1);
|
|
448
|
+
expect(footerOf(invokeRender())).not.toContain('older'); // no keys offered with nowhere to go
|
|
449
|
+
expect(footerOf(invokeRender())).toContain('showing 2 of 2');
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
// The board owns the terminal while it runs, so it also owns stdin: raw mode
|
|
453
|
+
// for the page keys, and the ctrl-c the terminal no longer translates for it.
|
|
454
|
+
describe('PRBoard — key input', () => {
|
|
455
|
+
let board;
|
|
456
|
+
let originalStdin;
|
|
457
|
+
let originalIsTTY;
|
|
458
|
+
let originalRows;
|
|
459
|
+
let originalColumns;
|
|
460
|
+
let originalWrite;
|
|
461
|
+
let fake;
|
|
462
|
+
const makeFakeStdin = () => {
|
|
463
|
+
const emitter = new EventEmitter();
|
|
464
|
+
emitter.isTTY = true;
|
|
465
|
+
emitter.isRaw = false;
|
|
466
|
+
emitter.rawModeCalls = [];
|
|
467
|
+
emitter.paused = false;
|
|
468
|
+
emitter.setRawMode = (on) => { emitter.isRaw = on; emitter.rawModeCalls.push(on); return emitter; };
|
|
469
|
+
emitter.resume = () => { emitter.paused = false; return emitter; };
|
|
470
|
+
emitter.pause = () => { emitter.paused = true; return emitter; };
|
|
471
|
+
return emitter;
|
|
472
|
+
};
|
|
473
|
+
beforeEach(() => {
|
|
474
|
+
originalStdin = process.stdin;
|
|
475
|
+
originalIsTTY = process.stdout.isTTY;
|
|
476
|
+
originalRows = process.stdout.rows;
|
|
477
|
+
originalColumns = process.stdout.columns;
|
|
478
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
|
|
479
|
+
Object.defineProperty(process.stdout, 'rows', { value: 20, configurable: true });
|
|
480
|
+
Object.defineProperty(process.stdout, 'columns', { value: 120, configurable: true });
|
|
481
|
+
originalWrite = process.stdout.write.bind(process.stdout);
|
|
482
|
+
process.stdout.write = (() => true);
|
|
483
|
+
fake = makeFakeStdin();
|
|
484
|
+
Object.defineProperty(process, 'stdin', { value: fake, configurable: true });
|
|
485
|
+
board = new PRBoard();
|
|
486
|
+
board.setConfig(baseConfig, [reviewStep]);
|
|
487
|
+
});
|
|
488
|
+
afterEach(() => {
|
|
489
|
+
board.stop();
|
|
490
|
+
Object.defineProperty(process, 'stdin', { value: originalStdin, configurable: true });
|
|
491
|
+
process.stdout.write = originalWrite;
|
|
492
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true });
|
|
493
|
+
Object.defineProperty(process.stdout, 'rows', { value: originalRows, configurable: true });
|
|
494
|
+
Object.defineProperty(process.stdout, 'columns', { value: originalColumns, configurable: true });
|
|
495
|
+
});
|
|
496
|
+
const page = () => board.page;
|
|
497
|
+
const fillHistory = () => {
|
|
498
|
+
for (let i = 0; i < 40; i++) {
|
|
499
|
+
board.addPR(`k${i}`, i, 'acme/api', `branch-${i}`);
|
|
500
|
+
board.completePR(`k${i}`, { elapsedMs: 1000, url: `https://github.com/acme/api/pull/${i}` });
|
|
501
|
+
}
|
|
502
|
+
;
|
|
503
|
+
board.render();
|
|
504
|
+
};
|
|
505
|
+
it('pages with the key sequences while running, and stops listening once stopped', () => {
|
|
506
|
+
fillHistory();
|
|
507
|
+
board.start();
|
|
508
|
+
expect(fake.isRaw).toBe(true);
|
|
509
|
+
fake.emit('data', Buffer.from('<'));
|
|
510
|
+
expect(page()).toBe(1);
|
|
511
|
+
fake.emit('data', Buffer.from('<'));
|
|
512
|
+
expect(page()).toBe(2);
|
|
513
|
+
fake.emit('data', Buffer.from('>'));
|
|
514
|
+
expect(page()).toBe(1);
|
|
515
|
+
board.stop();
|
|
516
|
+
expect(fake.isRaw).toBe(false); // terminal handed back
|
|
517
|
+
expect(fake.listenerCount('data')).toBe(0);
|
|
518
|
+
fake.emit('data', Buffer.from('<'));
|
|
519
|
+
expect(page()).toBe(1); // no longer listening
|
|
520
|
+
});
|
|
521
|
+
it('raises SIGINT itself, since raw mode suppresses the terminal ctrl-c', () => {
|
|
522
|
+
const kill = vi.spyOn(process, 'kill').mockImplementation(() => true);
|
|
523
|
+
board.start();
|
|
524
|
+
fake.emit('data', Buffer.from('\u0003'));
|
|
525
|
+
expect(kill).toHaveBeenCalledWith(process.pid, 'SIGINT');
|
|
526
|
+
kill.mockRestore();
|
|
527
|
+
});
|
|
528
|
+
it('ignores keys that are not page keys', () => {
|
|
529
|
+
fillHistory();
|
|
530
|
+
board.start();
|
|
531
|
+
fake.emit('data', Buffer.from('q'));
|
|
532
|
+
fake.emit('data', Buffer.from('\u001b[A'));
|
|
533
|
+
expect(page()).toBe(0);
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
describe('pageKeyAction', () => {
|
|
537
|
+
it('maps bare < and > and their unshifted keys', () => {
|
|
538
|
+
expect(pageKeyAction('<')).toBe('older');
|
|
539
|
+
expect(pageKeyAction(',')).toBe('older');
|
|
540
|
+
expect(pageKeyAction('>')).toBe('newer');
|
|
541
|
+
expect(pageKeyAction('.')).toBe('newer');
|
|
542
|
+
});
|
|
543
|
+
it('maps the CSI-u encodings terminals use for ctrl/cmd + punctuation', () => {
|
|
544
|
+
expect(pageKeyAction('\u001b[44;5u')).toBe('older'); // ctrl+,
|
|
545
|
+
expect(pageKeyAction('\u001b[46;5u')).toBe('newer'); // ctrl+.
|
|
546
|
+
expect(pageKeyAction('\u001b[60;9u')).toBe('older'); // cmd+<
|
|
547
|
+
expect(pageKeyAction('\u001b[62;9u')).toBe('newer'); // cmd+>
|
|
548
|
+
});
|
|
549
|
+
it('maps plain and modified arrows', () => {
|
|
550
|
+
expect(pageKeyAction('\u001b[D')).toBe('older');
|
|
551
|
+
expect(pageKeyAction('\u001b[C')).toBe('newer');
|
|
552
|
+
expect(pageKeyAction('\u001b[1;5D')).toBe('older');
|
|
553
|
+
expect(pageKeyAction('\u001b[1;5C')).toBe('newer');
|
|
554
|
+
});
|
|
555
|
+
it('maps the option+arrow and application-cursor forms macOS terminals send', () => {
|
|
556
|
+
expect(pageKeyAction('\u001bb')).toBe('older'); // Terminal.app option+left
|
|
557
|
+
expect(pageKeyAction('\u001bf')).toBe('newer'); // Terminal.app option+right
|
|
558
|
+
expect(pageKeyAction('\u001b\u001b[D')).toBe('older'); // iTerm2 option+left
|
|
559
|
+
expect(pageKeyAction('\u001b\u001b[C')).toBe('newer'); // iTerm2 option+right
|
|
560
|
+
expect(pageKeyAction('\u001bOD')).toBe('older'); // application-cursor left
|
|
561
|
+
expect(pageKeyAction('\u001bOC')).toBe('newer'); // application-cursor right
|
|
562
|
+
});
|
|
563
|
+
it('ignores everything else', () => {
|
|
564
|
+
expect(pageKeyAction('a')).toBe(null);
|
|
565
|
+
expect(pageKeyAction('\u0003')).toBe(null);
|
|
566
|
+
expect(pageKeyAction('\u001b[A')).toBe(null);
|
|
567
|
+
expect(pageKeyAction('\u001b[48;5u')).toBe(null);
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
describe('isNoticeLine', () => {
|
|
571
|
+
it('keeps warnings and errors on the terminal', () => {
|
|
572
|
+
expect(isNoticeLine('⚠ push rejected')).toBe(true);
|
|
573
|
+
expect(isNoticeLine('✗ codex did not review PR #1')).toBe(true);
|
|
574
|
+
expect(isNoticeLine('\u001b[33m⚠ usage limit\u001b[39m')).toBe(true);
|
|
575
|
+
});
|
|
576
|
+
it('keeps multi-line dumps, which always follow a notice', () => {
|
|
577
|
+
expect(isNoticeLine('\n--- unposted review ---\nbody\n--- end ---')).toBe(true);
|
|
578
|
+
});
|
|
579
|
+
it('routes routine narration to the file log', () => {
|
|
580
|
+
expect(isNoticeLine(' strategy v1.2.0: trivial → fast tier (medium)')).toBe(false);
|
|
581
|
+
expect(isNoticeLine(' skills: typescript')).toBe(false);
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
describe('distribute', () => {
|
|
585
|
+
it('returns all zeroes when nothing has been counted', () => {
|
|
586
|
+
expect(distribute([0, 0, 0])).toEqual([0, 0, 0]);
|
|
587
|
+
});
|
|
588
|
+
it('sums to exactly 100 where plain rounding would not', () => {
|
|
589
|
+
// Three equal shares floor to 33 each; the leftover point goes to a remainder.
|
|
590
|
+
const out = distribute([1, 1, 1]);
|
|
591
|
+
expect(out.reduce((a, b) => a + b, 0)).toBe(100);
|
|
592
|
+
expect(out.sort((a, b) => a - b)).toEqual([33, 33, 34]);
|
|
593
|
+
});
|
|
594
|
+
it('sums to 100 across a spread of awkward splits', () => {
|
|
595
|
+
const cases = [[1, 2], [1, 1, 1, 1, 1, 1], [7, 11, 13], [95, 1, 1, 1, 1, 1], [1, 999]];
|
|
596
|
+
for (const c of cases) {
|
|
597
|
+
expect(distribute(c).reduce((a, b) => a + b, 0)).toBe(100);
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
it('gives a lone outcome the whole 100%', () => {
|
|
601
|
+
expect(distribute([43, 0, 0])).toEqual([100, 0, 0]);
|
|
602
|
+
});
|
|
603
|
+
it('reports 0 for a share too small to earn a point, so callers can say "<1%"', () => {
|
|
604
|
+
const out = distribute([999, 1]);
|
|
605
|
+
expect(out[1]).toBe(0);
|
|
606
|
+
expect(out.reduce((a, b) => a + b, 0)).toBe(100);
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
describe('fmtUptime', () => {
|
|
610
|
+
it('renders minutes only under an hour', () => {
|
|
611
|
+
expect(fmtUptime(0)).toBe('0m');
|
|
612
|
+
expect(fmtUptime(59_000)).toBe('0m');
|
|
613
|
+
expect(fmtUptime(12 * 60_000)).toBe('12m');
|
|
614
|
+
});
|
|
615
|
+
it('renders hours and zero-padded minutes past the hour', () => {
|
|
616
|
+
expect(fmtUptime(60 * 60_000)).toBe('1h00m');
|
|
617
|
+
expect(fmtUptime((3 * 60 + 32) * 60_000)).toBe('3h32m');
|
|
618
|
+
expect(fmtUptime((11 * 60 + 57) * 60_000)).toBe('11h57m');
|
|
619
|
+
});
|
|
620
|
+
it('never renders a negative age if the clock steps backwards', () => {
|
|
621
|
+
expect(fmtUptime(-5000)).toBe('0m');
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
describe('PRBoard \u2014 failed PRs stay in the workspace', () => {
|
|
625
|
+
let board;
|
|
626
|
+
let originalIsTTY;
|
|
627
|
+
let originalRows;
|
|
628
|
+
let originalWrite;
|
|
629
|
+
let written;
|
|
630
|
+
beforeEach(() => {
|
|
631
|
+
originalIsTTY = process.stdout.isTTY;
|
|
632
|
+
originalRows = process.stdout.rows;
|
|
633
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
|
|
634
|
+
Object.defineProperty(process.stdout, 'rows', { value: 200, configurable: true });
|
|
635
|
+
originalWrite = process.stdout.write.bind(process.stdout);
|
|
636
|
+
written = [];
|
|
637
|
+
process.stdout.write = ((chunk) => { written.push(String(chunk)); return true; });
|
|
638
|
+
board = new PRBoard();
|
|
639
|
+
board.setConfig(baseConfig, [reviewStep]);
|
|
640
|
+
});
|
|
641
|
+
afterEach(() => {
|
|
642
|
+
board.stop();
|
|
643
|
+
process.stdout.write = originalWrite;
|
|
644
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true });
|
|
645
|
+
Object.defineProperty(process.stdout, 'rows', { value: originalRows, configurable: true });
|
|
646
|
+
});
|
|
647
|
+
const slots = () => board.slots;
|
|
648
|
+
const invokeRender = () => board.render();
|
|
649
|
+
it('keeps the slot instead of deleting it', () => {
|
|
650
|
+
board.addPR('k1', 4641, 'a/b', 'feat/x');
|
|
651
|
+
board.failPR('k1', 'codex: timed out after 600s');
|
|
652
|
+
expect(slots().size).toBe(1);
|
|
653
|
+
expect(slots().has('k1')).toBe(true);
|
|
654
|
+
});
|
|
655
|
+
it('renders the failed PR as a row carrying its error, not as scrollback', () => {
|
|
656
|
+
board.addPR('k1', 4641, 'a/b', 'feat/x');
|
|
657
|
+
board.failPR('k1', 'codex: timed out after 600s');
|
|
658
|
+
const output = stripAnsi(invokeRender());
|
|
659
|
+
expect(output).toContain('#4641');
|
|
660
|
+
expect(output).toContain('codex: timed out after 600s');
|
|
661
|
+
// The regression: the row went to scrollback and the table read "no PRs yet".
|
|
662
|
+
expect(output).not.toContain('no PRs yet');
|
|
663
|
+
expect(written.join('')).not.toContain('codex: timed out after 600s');
|
|
664
|
+
});
|
|
665
|
+
it('folds a failed row even when it is the only one on the page', () => {
|
|
666
|
+
board.addPR('k1', 4641, 'a/b', 'feat/x');
|
|
667
|
+
board.failPR('k1', 'codex: timed out after 600s');
|
|
668
|
+
const output = stripAnsi(invokeRender());
|
|
669
|
+
// Folded rows carry no pipeline bars; an expanded row would show "CR" queued
|
|
670
|
+
// against work the failed run will never do.
|
|
671
|
+
expect(output).not.toMatch(/CR [\u2588\u2591]/);
|
|
672
|
+
expect(output).toContain('\u2717 #4641');
|
|
673
|
+
});
|
|
674
|
+
it('flattens a multi-line subprocess dump into a single row', () => {
|
|
675
|
+
board.addPR('k1', 4823, 'a/b', 'feat/x');
|
|
676
|
+
board.failPR('k1', 'claude: Command failed with exit code 1: claude --print\n\n{"is_error":true,\n"result":"limit reached"}');
|
|
677
|
+
const rows = stripAnsi(invokeRender()).split('\n').filter(l => l.includes('#4823'));
|
|
678
|
+
// One row per folded slot is what history pagination sizes a page by.
|
|
679
|
+
expect(rows).toHaveLength(1);
|
|
680
|
+
expect(rows[0]).not.toContain('{');
|
|
681
|
+
expect(rows[0]).toContain('claude: Command failed');
|
|
682
|
+
});
|
|
683
|
+
it('counts a failure once, toward errors and the outcome split', () => {
|
|
684
|
+
board.addPR('k1', 1, 'a/b', 'feat/x');
|
|
685
|
+
board.failPR('k1', 'boom');
|
|
686
|
+
const stats = board.stats;
|
|
687
|
+
expect(stats.errorsOccurred).toBe(1);
|
|
688
|
+
expect(stats.outcomes.error).toBe(1);
|
|
689
|
+
});
|
|
690
|
+
it('still counts the error when the slot is already gone', () => {
|
|
691
|
+
board.failPR('never-added', 'boom');
|
|
692
|
+
const stats = board.stats;
|
|
693
|
+
expect(stats.errorsOccurred).toBe(1);
|
|
694
|
+
// No slot means no row, so nothing should be added to the distribution.
|
|
695
|
+
expect(stats.outcomes.error).toBe(0);
|
|
696
|
+
});
|
|
697
|
+
it('pages failed rows into history like any other settled row', () => {
|
|
698
|
+
for (let i = 0; i < 40; i++) {
|
|
699
|
+
board.addPR(`k${i}`, 4600 + i, 'a/b', `feat/${i}`);
|
|
700
|
+
board.failPR(`k${i}`, 'codex: timed out after 600s');
|
|
701
|
+
}
|
|
702
|
+
invokeRender();
|
|
703
|
+
expect(slots().size).toBe(40);
|
|
704
|
+
const pageCount = board.pageCount;
|
|
705
|
+
expect(pageCount).toBeGreaterThan(0);
|
|
706
|
+
});
|
|
707
|
+
it('emits the folded line to scrollback and drops the slot when not a TTY', () => {
|
|
708
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true });
|
|
709
|
+
const nonTty = new PRBoard();
|
|
710
|
+
nonTty.setConfig(baseConfig, [reviewStep]);
|
|
711
|
+
nonTty.addPR('k1', 4641, 'a/b', 'feat/x');
|
|
712
|
+
nonTty.failPR('k1', 'codex: timed out after 600s');
|
|
713
|
+
expect(stripAnsi(written.join(''))).toContain('codex: timed out after 600s');
|
|
714
|
+
expect(nonTty.slots.size).toBe(0);
|
|
715
|
+
});
|
|
716
|
+
});
|
|
717
|
+
describe('PRBoard \u2014 session stats panel', () => {
|
|
718
|
+
let board;
|
|
719
|
+
let originalIsTTY;
|
|
720
|
+
let originalRows;
|
|
721
|
+
let originalWrite;
|
|
722
|
+
beforeEach(() => {
|
|
723
|
+
originalIsTTY = process.stdout.isTTY;
|
|
724
|
+
originalRows = process.stdout.rows;
|
|
725
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
|
|
726
|
+
Object.defineProperty(process.stdout, 'rows', { value: 200, configurable: true });
|
|
727
|
+
originalWrite = process.stdout.write.bind(process.stdout);
|
|
728
|
+
process.stdout.write = (() => true);
|
|
729
|
+
board = new PRBoard();
|
|
730
|
+
board.setConfig(baseConfig, [reviewStep]);
|
|
731
|
+
});
|
|
732
|
+
afterEach(() => {
|
|
733
|
+
board.stop();
|
|
734
|
+
process.stdout.write = originalWrite;
|
|
735
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true });
|
|
736
|
+
Object.defineProperty(process.stdout, 'rows', { value: originalRows, configurable: true });
|
|
737
|
+
});
|
|
738
|
+
const invokeRender = () => board.render();
|
|
739
|
+
const setStart = (ms) => (board.stats.sessionStart = ms);
|
|
740
|
+
it('shows the local start time and the session age', () => {
|
|
741
|
+
const start = Date.now() - (3 * 60 + 32) * 60_000;
|
|
742
|
+
setStart(start);
|
|
743
|
+
const output = stripAnsi(invokeRender());
|
|
744
|
+
const expected = new Date(start).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true });
|
|
745
|
+
expect(output).toContain(`since ${expected}`);
|
|
746
|
+
expect(output).toContain('up 3h32m');
|
|
747
|
+
});
|
|
748
|
+
it('omits the outcome split until something settles', () => {
|
|
749
|
+
const output = stripAnsi(invokeRender());
|
|
750
|
+
expect(output).toContain('since');
|
|
751
|
+
expect(output).not.toContain('%');
|
|
752
|
+
});
|
|
753
|
+
it('reports the outcome split as percentages that sum to 100', () => {
|
|
754
|
+
const verdicts = ['APPROVE', 'APPROVE', 'BLOCK', 'NEEDS WORK'];
|
|
755
|
+
verdicts.forEach((v, i) => {
|
|
756
|
+
board.addPR(`k${i}`, i, 'a/b', `b${i}`);
|
|
757
|
+
board.updatePR(`k${i}`, { verdict: v });
|
|
758
|
+
board.completePR(`k${i}`, { elapsedMs: 1000, url: `u${i}` });
|
|
759
|
+
});
|
|
760
|
+
const output = stripAnsi(invokeRender());
|
|
761
|
+
expect(output).toContain('APPROVE 50%');
|
|
762
|
+
expect(output).toContain('BLOCK 25%');
|
|
763
|
+
expect(output).toContain('NEEDS WORK 25%');
|
|
764
|
+
});
|
|
765
|
+
it('counts a failure in the split alongside verdicts', () => {
|
|
766
|
+
board.addPR('ok', 1, 'a/b', 'b1');
|
|
767
|
+
board.updatePR('ok', { verdict: 'APPROVE' });
|
|
768
|
+
board.completePR('ok', { elapsedMs: 1000, url: 'u1' });
|
|
769
|
+
board.addPR('bad', 2, 'a/b', 'b2');
|
|
770
|
+
board.failPR('bad', 'codex: timed out after 600s');
|
|
771
|
+
const output = stripAnsi(invokeRender());
|
|
772
|
+
expect(output).toContain('APPROVE 50%');
|
|
773
|
+
expect(output).toContain('error 50%');
|
|
774
|
+
});
|
|
775
|
+
it('takes the recheck verdict as the outcome when a recheck ran', () => {
|
|
776
|
+
board.addPR('k1', 1, 'a/b', 'b1');
|
|
777
|
+
board.updatePR('k1', { verdict: 'BLOCK', recheckVerdict: 'APPROVE' });
|
|
778
|
+
board.completePR('k1', { elapsedMs: 1000, url: 'u1' });
|
|
779
|
+
// Scoped to the stats block: the PR row still shows the round's own BLOCK in
|
|
780
|
+
// its CR bar, which is correct — only the split should read APPROVE.
|
|
781
|
+
const stats = stripAnsi(invokeRender()).split(/^─+$/m)[1] ?? '';
|
|
782
|
+
expect(stats).toContain('APPROVE 100%');
|
|
783
|
+
expect(stats).not.toContain('BLOCK');
|
|
784
|
+
});
|
|
785
|
+
it('separates a reviewer that returned nothing from one that never ran', () => {
|
|
786
|
+
board.addPR('noverdict', 1, 'a/b', 'b1');
|
|
787
|
+
board.updatePR('noverdict', { verdict: null });
|
|
788
|
+
board.completePR('noverdict', { elapsedMs: 1000, url: 'u1' });
|
|
789
|
+
board.addPR('skipped', 2, 'a/b', 'b2');
|
|
790
|
+
board.completePR('skipped', { elapsedMs: 900, url: 'u2', label: 'skipped \u00b7 generated' });
|
|
791
|
+
const output = stripAnsi(invokeRender());
|
|
792
|
+
expect(output).toContain('no verdict 50%');
|
|
793
|
+
expect(output).toContain('skipped 50%');
|
|
794
|
+
});
|
|
795
|
+
it('never pairs a "<1%" outcome with a flat 100%', () => {
|
|
796
|
+
for (let i = 0; i < 999; i++) {
|
|
797
|
+
board.addPR(`e${i}`, i, 'a/b', 'x');
|
|
798
|
+
board.failPR(`e${i}`, 'boom');
|
|
799
|
+
}
|
|
800
|
+
board.addPR('ok', 9999, 'a/b', 'y');
|
|
801
|
+
board.updatePR('ok', { verdict: 'APPROVE' });
|
|
802
|
+
board.completePR('ok', { elapsedMs: 1, url: 'u' });
|
|
803
|
+
const output = stripAnsi(invokeRender());
|
|
804
|
+
expect(output).toContain('APPROVE <1%');
|
|
805
|
+
expect(output).toContain('error >99%');
|
|
806
|
+
expect(output).not.toContain('error 100%');
|
|
807
|
+
});
|
|
808
|
+
it('still reads a clean 100% when one outcome is the only one', () => {
|
|
809
|
+
for (let i = 0; i < 5; i++) {
|
|
810
|
+
board.addPR(`e${i}`, i, 'a/b', 'x');
|
|
811
|
+
board.failPR(`e${i}`, 'boom');
|
|
812
|
+
}
|
|
813
|
+
expect(stripAnsi(invokeRender())).toContain('error 100%');
|
|
814
|
+
});
|
|
815
|
+
it('keeps the cumulative split after the slot history cap drops old rows', () => {
|
|
816
|
+
const stats = board.stats;
|
|
817
|
+
board.addPR('k1', 1, 'a/b', 'b1');
|
|
818
|
+
board.updatePR('k1', { verdict: 'APPROVE' });
|
|
819
|
+
board.completePR('k1', { elapsedMs: 1000, url: 'u1' });
|
|
820
|
+
board.slots.clear();
|
|
821
|
+
expect(stats.outcomes.APPROVE).toBe(1);
|
|
822
|
+
expect(stripAnsi(invokeRender())).toContain('APPROVE 100%');
|
|
823
|
+
});
|
|
317
824
|
});
|
|
318
825
|
// Minimal VT emulator: LF scrolls at the bottom row, CUU clamps at the viewport
|
|
319
826
|
// top, ED-0J clears cursor→end-of-screen, autowrap is deferred at the last
|