@polderlabs/bizar 5.0.0 → 5.0.2
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/bizar-dash/dist/assets/{icons-CFqu2M-c.js → icons-Bo0iH9EC.js} +166 -121
- package/bizar-dash/dist/assets/icons-Bo0iH9EC.js.map +1 -0
- package/bizar-dash/dist/assets/main-C1cpttnv.js +19 -0
- package/bizar-dash/dist/assets/main-C1cpttnv.js.map +1 -0
- package/bizar-dash/dist/assets/main-DTkNlLrw.css +1 -0
- package/bizar-dash/dist/assets/mobile-BtxQJftK.js +2 -0
- package/bizar-dash/dist/assets/mobile-BtxQJftK.js.map +1 -0
- package/bizar-dash/dist/assets/{mobile-DHXXbn1A.js → mobile-DD-FZrTC.js} +1 -1
- package/bizar-dash/dist/assets/{mobile-DHXXbn1A.js.map → mobile-DD-FZrTC.js.map} +1 -1
- package/bizar-dash/dist/index.html +4 -4
- package/bizar-dash/dist/mobile.html +3 -3
- package/bizar-dash/node_modules/.package-lock.json +6 -0
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/package-lock.json +6 -0
- package/bizar-dash/src/server/api.mjs +7 -0
- package/bizar-dash/src/server/diagnostics-store.mjs +452 -2
- package/bizar-dash/src/server/memory-store.mjs +46 -0
- package/bizar-dash/src/server/providers-store.mjs +11 -4
- package/bizar-dash/src/server/routes/_shared.mjs +2 -2
- package/bizar-dash/src/server/routes/doctor.mjs +71 -0
- package/bizar-dash/src/server/routes/env-vars.mjs +67 -1
- package/bizar-dash/src/server/routes/memory.mjs +3 -0
- package/bizar-dash/src/server/routes/opencode-session-detail.mjs +61 -42
- package/bizar-dash/src/server/routes/schedules.mjs +55 -0
- package/bizar-dash/src/server/serve-info.mjs +172 -0
- package/bizar-dash/src/web/App.tsx +48 -11
- package/bizar-dash/src/web/components/AutosaveField.tsx +50 -0
- package/bizar-dash/src/web/components/DoctorPanel.tsx +160 -0
- package/bizar-dash/src/web/components/EnvVarManager.tsx +245 -62
- package/bizar-dash/src/web/components/ScheduleTemplateCard.tsx +71 -0
- package/bizar-dash/src/web/components/SettingsNav.tsx +101 -0
- package/bizar-dash/src/web/components/Sidebar.tsx +25 -1
- package/bizar-dash/src/web/components/StatusBadge.tsx +33 -2
- package/bizar-dash/src/web/components/Topbar.tsx +11 -0
- package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +64 -0
- package/bizar-dash/src/web/components/chat/Composer.tsx +1 -1
- package/bizar-dash/src/web/components/chat/useChat.ts +118 -1
- package/bizar-dash/src/web/hooks/useAutosave.ts +107 -0
- package/bizar-dash/src/web/lib/types.ts +134 -0
- package/bizar-dash/src/web/styles/chat.css +8 -5
- package/bizar-dash/src/web/styles/main.css +77 -2
- package/bizar-dash/src/web/styles/settings.css +265 -0
- package/bizar-dash/src/web/views/Chat.tsx +15 -1
- package/bizar-dash/src/web/views/Doctor.tsx +317 -0
- package/bizar-dash/src/web/views/MiniMaxUsage.tsx +1 -0
- package/bizar-dash/src/web/views/Schedules.tsx +94 -12
- package/bizar-dash/src/web/views/Settings.tsx +76 -46
- package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +114 -133
- package/bizar-dash/src/web/views/settings/AgentSection.tsx +23 -12
- package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +5 -6
- package/bizar-dash/src/web/views/settings/GeneralSection.tsx +38 -15
- package/bizar-dash/src/web/views/settings/MemorySection.tsx +92 -8
- package/bizar-dash/tests/autosave.test.tsx +276 -0
- package/bizar-dash/tests/chat-composer.test.tsx +140 -0
- package/bizar-dash/tests/components/doctor-panel.test.tsx +105 -0
- package/bizar-dash/tests/diagnostics-store.test.mjs +206 -0
- package/bizar-dash/tests/env-vars-extended.test.mjs +190 -0
- package/bizar-dash/tests/layout-spacing.test.mjs +101 -0
- package/bizar-dash/tests/memory-default-vault.test.mjs +98 -0
- package/bizar-dash/tests/minimax-bar.test.tsx +113 -0
- package/bizar-dash/tests/minimax-models.test.mjs +123 -0
- package/bizar-dash/tests/opencode-sessions-detail.test.mjs +158 -0
- package/bizar-dash/tests/routes-doctor.test.mjs +159 -0
- package/bizar-dash/tests/schedules-templates.test.mjs +144 -0
- package/bizar-dash/tests/settings-layout.test.tsx +129 -0
- package/bizar-dash/tests/settings-mode-wiring.test.tsx +151 -0
- package/bizar-dash/tests/settings-nav.test.tsx +126 -0
- package/cli/commands/dash.mjs +5 -1
- package/cli/commands/minimax.mjs +5 -2
- package/cli/tests/minimax-cli.test.mjs +79 -0
- package/config/agents/frigg.md +1 -1
- package/config/agents/heimdall.md +1 -1
- package/config/agents/mimir.md +1 -1
- package/config/agents/quick.md +1 -1
- package/config/agents/semble-search.md +1 -1
- package/config/agents/vor.md +1 -1
- package/config/opencode.json +28 -21
- package/config/opencode.json.template +8 -1
- package/package.json +1 -1
- package/plugins/bizar/index.ts +77 -0
- package/plugins/bizar/src/compaction.d.mts +48 -0
- package/plugins/bizar/src/compaction.mjs +192 -0
- package/plugins/bizar/tests/compaction.test.ts +264 -0
- package/templates/schedules/daily-backup.json +12 -0
- package/templates/schedules/daily-cleanup.json +12 -0
- package/templates/schedules/hourly-health-check.json +12 -0
- package/templates/schedules/webhook-on-push.json +13 -0
- package/templates/schedules/weekly-digest.json +13 -0
- package/bizar-dash/dist/assets/icons-CFqu2M-c.js.map +0 -1
- package/bizar-dash/dist/assets/main-Dl8yY5_H.js +0 -16
- package/bizar-dash/dist/assets/main-Dl8yY5_H.js.map +0 -1
- package/bizar-dash/dist/assets/main-ZAfGKENE.css +0 -1
- package/bizar-dash/dist/assets/mobile-C2gysFOZ.js +0 -2
- package/bizar-dash/dist/assets/mobile-C2gysFOZ.js.map +0 -1
- package/bizar-dash/src/web/views/settings/BackupSection.tsx +0 -16
- package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +0 -16
- package/bizar-dash/src/web/views/settings/SkillsSection.tsx +0 -16
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bizar-dash/tests/minimax-bar.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Tests for MiniMax quota bar in the dashboard.
|
|
5
|
+
* Verifies bar width = consumedPct (not remainingPct).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect } from 'vitest';
|
|
9
|
+
import { render, screen } from '@testing-library/react';
|
|
10
|
+
import React from 'react';
|
|
11
|
+
|
|
12
|
+
// ── Minimal QuotaBar clone for testing ──────────────────────────────────────
|
|
13
|
+
// This mirrors the logic in MiniMaxUsage.tsx QuotaBar component.
|
|
14
|
+
|
|
15
|
+
function clamp(n: number, lo: number, hi: number): number {
|
|
16
|
+
return Math.max(lo, Math.min(hi, n));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function QuotaBarTest({ remainingPct, consumedPct }: { remainingPct: number; consumedPct: number }) {
|
|
20
|
+
return (
|
|
21
|
+
<div data-testid="quota-row">
|
|
22
|
+
<div data-testid="remaining-label">{remainingPct}% remaining</div>
|
|
23
|
+
<div data-testid="consumed-label">· {consumedPct}% used</div>
|
|
24
|
+
<div
|
|
25
|
+
data-testid="bar-fill"
|
|
26
|
+
style={{ width: `${consumedPct}%` }}
|
|
27
|
+
aria-label={`${consumedPct}% consumed`}
|
|
28
|
+
/>
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── Tests ───────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
describe('QuotaBar width logic', () => {
|
|
36
|
+
|
|
37
|
+
it('bar width equals consumedPct, not remainingPct', () => {
|
|
38
|
+
const remainingPct = 91;
|
|
39
|
+
const consumedPct = clamp(100 - remainingPct, 0, 100);
|
|
40
|
+
|
|
41
|
+
render(<QuotaBarTest remainingPct={remainingPct} consumedPct={consumedPct} />);
|
|
42
|
+
|
|
43
|
+
const barFill = screen.getByTestId('bar-fill');
|
|
44
|
+
const styleWidth = barFill.getAttribute('style');
|
|
45
|
+
|
|
46
|
+
// Bar width should be 9% (consumed), not 91% (remaining)
|
|
47
|
+
expect(styleWidth).toMatch(/width: 9%/);
|
|
48
|
+
expect(barFill.getAttribute('aria-label')).toBe('9% consumed');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('100% remaining → 0% consumed → 0% bar width', () => {
|
|
52
|
+
const remainingPct = 100;
|
|
53
|
+
const consumedPct = clamp(100 - remainingPct, 0, 100);
|
|
54
|
+
|
|
55
|
+
render(<QuotaBarTest remainingPct={remainingPct} consumedPct={consumedPct} />);
|
|
56
|
+
|
|
57
|
+
const barFill = screen.getByTestId('bar-fill');
|
|
58
|
+
expect(barFill.getAttribute('style')).toMatch(/width: 0%/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('0% remaining → 100% consumed → 100% bar width', () => {
|
|
62
|
+
const remainingPct = 0;
|
|
63
|
+
const consumedPct = clamp(100 - remainingPct, 0, 100);
|
|
64
|
+
|
|
65
|
+
render(<QuotaBarTest remainingPct={remainingPct} consumedPct={consumedPct} />);
|
|
66
|
+
|
|
67
|
+
const barFill = screen.getByTestId('bar-fill');
|
|
68
|
+
expect(barFill.getAttribute('style')).toMatch(/width: 100%/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('75% remaining → 25% consumed → 25% bar width', () => {
|
|
72
|
+
const remainingPct = 75;
|
|
73
|
+
const consumedPct = clamp(100 - remainingPct, 0, 100);
|
|
74
|
+
|
|
75
|
+
render(<QuotaBarTest remainingPct={remainingPct} consumedPct={consumedPct} />);
|
|
76
|
+
|
|
77
|
+
const barFill = screen.getByTestId('bar-fill');
|
|
78
|
+
expect(barFill.getAttribute('style')).toMatch(/width: 25%/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('consumedPct = 100 - remainingPct', () => {
|
|
82
|
+
const testCases = [
|
|
83
|
+
{ remaining: 100, expectedConsumed: 0 },
|
|
84
|
+
{ remaining: 91, expectedConsumed: 9 },
|
|
85
|
+
{ remaining: 75, expectedConsumed: 25 },
|
|
86
|
+
{ remaining: 50, expectedConsumed: 50 },
|
|
87
|
+
{ remaining: 25, expectedConsumed: 75 },
|
|
88
|
+
{ remaining: 9, expectedConsumed: 91 },
|
|
89
|
+
{ remaining: 0, expectedConsumed: 100 },
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
for (const { remaining, expectedConsumed } of testCases) {
|
|
93
|
+
const consumed = clamp(100 - remaining, 0, 100);
|
|
94
|
+
expect(consumed).toBe(expectedConsumed);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('clamp keeps values within 0-100 range', () => {
|
|
99
|
+
expect(clamp(-10, 0, 100)).toBe(0);
|
|
100
|
+
expect(clamp(110, 0, 100)).toBe(100);
|
|
101
|
+
expect(clamp(50, 0, 100)).toBe(50);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('labels show both remaining and consumed', () => {
|
|
105
|
+
const remainingPct = 91;
|
|
106
|
+
const consumedPct = clamp(100 - remainingPct, 0, 100);
|
|
107
|
+
|
|
108
|
+
render(<QuotaBarTest remainingPct={remainingPct} consumedPct={consumedPct} />);
|
|
109
|
+
|
|
110
|
+
expect(screen.getByTestId('remaining-label').textContent).toBe('91% remaining');
|
|
111
|
+
expect(screen.getByTestId('consumed-label').textContent).toBe('· 9% used');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* minimax-models.test.mjs — verify MiniMax is the default/primary provider
|
|
3
|
+
* and that the catalog, constants, and agent configs all agree.
|
|
4
|
+
*
|
|
5
|
+
* Run with: node --test tests/minimax-models.test.mjs
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, before } from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { resolve, join } from 'node:path';
|
|
10
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
|
|
13
|
+
const REPO = resolve(import.meta.dirname, '..', '..');
|
|
14
|
+
|
|
15
|
+
let mod;
|
|
16
|
+
let AGENTS_DIR;
|
|
17
|
+
|
|
18
|
+
before(async () => {
|
|
19
|
+
const cacheBust = '?cb=' + Date.now() + '-' + Math.random().toString(36).slice(2);
|
|
20
|
+
mod = await import(join(REPO, 'bizar-dash/src/server/providers-store.mjs') + cacheBust);
|
|
21
|
+
AGENTS_DIR = join(REPO, 'config', 'agents');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('MiniMax model constants', () => {
|
|
25
|
+
it('MINIMAX_DEFAULT is MiniMax-M2.7', () => {
|
|
26
|
+
assert.equal(mod.MINIMAX_DEFAULT, 'MiniMax-M2.7');
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('PROVIDER_CATALOG MiniMax entry', () => {
|
|
31
|
+
it('minimax is present in the catalog', () => {
|
|
32
|
+
const minimax = mod.PROVIDER_CATALOG.find((p) => p.id === 'minimax');
|
|
33
|
+
assert.ok(minimax, 'minimax provider not found in PROVIDER_CATALOG');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('has at least 4 models', () => {
|
|
37
|
+
const minimax = mod.PROVIDER_CATALOG.find((p) => p.id === 'minimax');
|
|
38
|
+
assert.ok(minimax.models.length >= 4,
|
|
39
|
+
`expected at least 4 MiniMax models, got ${minimax.models.length}`);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('includes MiniMax-M2.7-Flash as recommended cheap model', () => {
|
|
43
|
+
const minimax = mod.PROVIDER_CATALOG.find((p) => p.id === 'minimax');
|
|
44
|
+
const flash = minimax.models.find((m) => {
|
|
45
|
+
const id = typeof m === 'string' ? m : m.id;
|
|
46
|
+
return id === 'MiniMax-M2.7-Flash';
|
|
47
|
+
});
|
|
48
|
+
assert.ok(flash, 'MiniMax-M2.7-Flash not found in catalog');
|
|
49
|
+
if (typeof flash === 'object') {
|
|
50
|
+
assert.equal(flash.tier, 'cheap');
|
|
51
|
+
assert.equal(flash.recommended, true);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('includes MiniMax-M2.7 (default cheap)', () => {
|
|
56
|
+
const minimax = mod.PROVIDER_CATALOG.find((p) => p.id === 'minimax');
|
|
57
|
+
const m27 = minimax.models.find((m) => {
|
|
58
|
+
const id = typeof m === 'string' ? m : m.id;
|
|
59
|
+
return id === 'MiniMax-M2.7';
|
|
60
|
+
});
|
|
61
|
+
assert.ok(m27, 'MiniMax-M2.7 not found in catalog');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('includes MiniMax-M3 (mid tier)', () => {
|
|
65
|
+
const minimax = mod.PROVIDER_CATALOG.find((p) => p.id === 'minimax');
|
|
66
|
+
const m3 = minimax.models.find((m) => {
|
|
67
|
+
const id = typeof m === 'string' ? m : m.id;
|
|
68
|
+
return id === 'MiniMax-M3';
|
|
69
|
+
});
|
|
70
|
+
assert.ok(m3, 'MiniMax-M3 not found in catalog');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('includes MiniMax-M3-Reasoning (premium)', () => {
|
|
74
|
+
const minimax = mod.PROVIDER_CATALOG.find((p) => p.id === 'minimax');
|
|
75
|
+
const m3r = minimax.models.find((m) => {
|
|
76
|
+
const id = typeof m === 'string' ? m : m.id;
|
|
77
|
+
return id === 'MiniMax-M3-Reasoning';
|
|
78
|
+
});
|
|
79
|
+
assert.ok(m3r, 'MiniMax-M3-Reasoning not found in catalog');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('Agent .md files use MiniMax models', () => {
|
|
84
|
+
// Agents that should be on minimax/MiniMax-M2.7
|
|
85
|
+
const m27Agents = ['frigg', 'heimdall', 'mimir', 'vor', 'semble-search', 'hermod', 'thor', 'baldr'];
|
|
86
|
+
// Agents that should be on minimax/MiniMax-M3
|
|
87
|
+
const m3Agents = ['tyr', 'vidarr', 'forseti', 'odin'];
|
|
88
|
+
// Agents that should be on minimax/MiniMax-M2.7-Flash
|
|
89
|
+
const flashAgents = ['quick'];
|
|
90
|
+
|
|
91
|
+
for (const agent of m27Agents) {
|
|
92
|
+
it(`${agent} uses minimax/MiniMax-M2.7`, () => {
|
|
93
|
+
const file = join(AGENTS_DIR, `${agent}.md`);
|
|
94
|
+
if (!existsSync(file)) { assert.fail(`agent file not found: ${file}`); }
|
|
95
|
+
const raw = readFileSync(file, 'utf8');
|
|
96
|
+
const match = raw.match(/^model:\s*([^\s#]+)/m);
|
|
97
|
+
assert.ok(match, `${agent}.md has no model: line`);
|
|
98
|
+
assert.equal(match[1], 'minimax/MiniMax-M2.7', `${agent} should use MiniMax-M2.7, got ${match[1]}`);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const agent of m3Agents) {
|
|
103
|
+
it(`${agent} uses minimax/MiniMax-M3`, () => {
|
|
104
|
+
const file = join(AGENTS_DIR, `${agent}.md`);
|
|
105
|
+
if (!existsSync(file)) { assert.fail(`agent file not found: ${file}`); }
|
|
106
|
+
const raw = readFileSync(file, 'utf8');
|
|
107
|
+
const match = raw.match(/^model:\s*([^\s#]+)/m);
|
|
108
|
+
assert.ok(match, `${agent}.md has no model: line`);
|
|
109
|
+
assert.equal(match[1], 'minimax/MiniMax-M3', `${agent} should use MiniMax-M3, got ${match[1]}`);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const agent of flashAgents) {
|
|
114
|
+
it(`${agent} uses minimax/MiniMax-M2.7-Flash`, () => {
|
|
115
|
+
const file = join(AGENTS_DIR, `${agent}.md`);
|
|
116
|
+
if (!existsSync(file)) { assert.fail(`agent file not found: ${file}`); }
|
|
117
|
+
const raw = readFileSync(file, 'utf8');
|
|
118
|
+
const match = raw.match(/^model:\s*([^\s#]+)/m);
|
|
119
|
+
assert.ok(match, `${agent}.md has no model: line`);
|
|
120
|
+
assert.equal(match[1], 'minimax/MiniMax-M2.7-Flash', `${agent} should use MiniMax-M2.7-Flash, got ${match[1]}`);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
});
|
|
@@ -64,8 +64,32 @@ function startUpstream() {
|
|
|
64
64
|
res.end(JSON.stringify({ data: [{ id: 'sess-1', title: 'Test', time: { created: 1, updated: 2, archived: null }, location: { directory: '/tmp/session-worktree' } }] }));
|
|
65
65
|
return;
|
|
66
66
|
}
|
|
67
|
+
// v5.0.0 — bug #4: per-session probe used by `worktreeHasSession`
|
|
68
|
+
// (resolveSessionDirectory fast-path). Matches `/api/session/{id}`
|
|
69
|
+
// (no `/message` or `/prompt` suffix). 200 when the session
|
|
70
|
+
// belongs to the directory in the query string; 404 otherwise.
|
|
71
|
+
const probeM = url.pathname.match(/^\/api\/session\/([^/]+)$/);
|
|
72
|
+
if (probeM && req.method === 'GET') {
|
|
73
|
+
const dir = url.searchParams.get('directory') || '';
|
|
74
|
+
// The session "lives" in /tmp/session-worktree (matches the
|
|
75
|
+
// recorded worktree in writeServeJson). All other directories
|
|
76
|
+
// 404 so we can test the fallback path.
|
|
77
|
+
if (dir === '/tmp/session-worktree') {
|
|
78
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
79
|
+
res.end(JSON.stringify({ id: probeM[1] }));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
83
|
+
res.end(JSON.stringify({ error: 'session not found in directory' }));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
67
86
|
const msgM = url.pathname.match(/^\/api\/session\/([^/]+)\/message$/);
|
|
68
87
|
if (msgM && req.method === 'GET') {
|
|
88
|
+
if (msgM[1] === 'sess-upstream-500') {
|
|
89
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
90
|
+
res.end(JSON.stringify({ error: 'internal server error for testing' }));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
69
93
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
70
94
|
res.end(JSON.stringify({ data: [
|
|
71
95
|
{ info: { id: 'm1', role: 'user', time: { created: 1000 } }, parts: [{ type: 'text', text: 'hello' }] },
|
|
@@ -128,6 +152,10 @@ function writeServeJson(worktree) {
|
|
|
128
152
|
writeFileSync(SERVE_JSON_PATH, JSON.stringify({ port: upstreamPort, password: 'test-pw', worktree, pid: 99999, startedAt: Date.now() }), 'utf8');
|
|
129
153
|
}
|
|
130
154
|
|
|
155
|
+
function writeServeJsonWithoutWorktree() {
|
|
156
|
+
writeFileSync(SERVE_JSON_PATH, JSON.stringify({ port: upstreamPort, password: 'test-pw', pid: 99999, startedAt: Date.now() }), 'utf8');
|
|
157
|
+
}
|
|
158
|
+
|
|
131
159
|
/** Move serve.json aside so readServeInfo() returns null.
|
|
132
160
|
* Uses copy + unlink because rename across filesystems (e.g.
|
|
133
161
|
* /home/* → /tmp/*) throws EXDEV on Linux. */
|
|
@@ -273,4 +301,134 @@ test('SSE stream unwraps sync envelopes and strips version suffix', async () =>
|
|
|
273
301
|
const d = JSON.parse(events[0].data);
|
|
274
302
|
assert.equal(d.sessionID, 'our-id');
|
|
275
303
|
assert.equal(d.messageID, 'm3');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// ── Error handling tests ─────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
test('GET messages returns 503 directory_unknown when worktree missing and session not found', async () => {
|
|
309
|
+
writeServeJsonWithoutWorktree();
|
|
310
|
+
try {
|
|
311
|
+
// Request a non-existent session ID so listOpencodeSessions doesn't find it,
|
|
312
|
+
// and with no worktree in serve.json, resolveSessionDirectory returns null.
|
|
313
|
+
const res = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/no-such-session/messages`);
|
|
314
|
+
assert.equal(res.status, 503);
|
|
315
|
+
const body = await res.json();
|
|
316
|
+
assert.equal(body.error, 'directory_unknown');
|
|
317
|
+
assert.ok(body.message.length > 0);
|
|
318
|
+
assert.ok(typeof body.suggestion === 'string');
|
|
319
|
+
} finally {
|
|
320
|
+
// Restore worktree for subsequent tests.
|
|
321
|
+
writeServeJson('/tmp/session-worktree');
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('GET messages returns 502 opencode_error when upstream returns 500', async () => {
|
|
326
|
+
writeServeJson('/tmp/session-worktree');
|
|
327
|
+
const res = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/sess-upstream-500/messages`);
|
|
328
|
+
assert.equal(res.status, 502);
|
|
329
|
+
const body = await res.json();
|
|
330
|
+
assert.equal(body.error, 'opencode_error');
|
|
331
|
+
assert.ok(body.message.length > 0);
|
|
332
|
+
assert.ok(body.cause === 'unknown' || typeof body.cause === 'string');
|
|
333
|
+
assert.ok(typeof body.suggestion === 'string');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test('GET messages uses suggestion field in error responses', async () => {
|
|
337
|
+
// Test that the plugin_offline response includes suggestion.
|
|
338
|
+
moveServeJsonAside();
|
|
339
|
+
try {
|
|
340
|
+
const res = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/sess-1/messages`);
|
|
341
|
+
assert.equal(res.status, 503);
|
|
342
|
+
const body = await res.json();
|
|
343
|
+
assert.equal(body.error, 'plugin_offline');
|
|
344
|
+
assert.ok(typeof body.suggestion === 'string');
|
|
345
|
+
assert.ok(body.suggestion.length > 0);
|
|
346
|
+
} finally {
|
|
347
|
+
// Restore so the after() cleanup finds the file.
|
|
348
|
+
writeServeJson('/tmp/session-worktree');
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// ── v5.0.0 — bug #4 — structured error envelopes + worktree fast-path ──
|
|
353
|
+
|
|
354
|
+
test('GET messages 502 envelope includes cause/status/suggestion on upstream failure', async () => {
|
|
355
|
+
// Issue: bug #4 — the user previously saw the raw error string
|
|
356
|
+
// ("listMessages network error: fetch failed") with no structured
|
|
357
|
+
// fields. The fix is to surface `cause`, `status`, and `suggestion`
|
|
358
|
+
// so the front-end ChatInfoPanel can render actionable UI.
|
|
359
|
+
writeServeJson('/tmp/session-worktree');
|
|
360
|
+
const res = await fetch(
|
|
361
|
+
`${dashboardBaseUrl}/api/opencode-sessions/sess-upstream-500/messages`,
|
|
362
|
+
);
|
|
363
|
+
assert.equal(res.status, 502);
|
|
364
|
+
const body = await res.json();
|
|
365
|
+
assert.equal(body.error, 'opencode_error');
|
|
366
|
+
assert.equal(typeof body.message, 'string');
|
|
367
|
+
assert.ok(body.message.length > 0);
|
|
368
|
+
// Upstream 500 → cause stays 'unknown' (no network code), and the
|
|
369
|
+
// route forwards the upstream's HTTP status so the operator can
|
|
370
|
+
// tell whether it's opencode that's broken vs. the dashboard.
|
|
371
|
+
assert.ok(typeof body.cause === 'string');
|
|
372
|
+
assert.equal(body.status, 500);
|
|
373
|
+
assert.ok(typeof body.suggestion === 'string');
|
|
374
|
+
assert.ok(body.suggestion.length > 0);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test('GET messages 503 plugin_offline envelope includes suggestion (operator guidance)', async () => {
|
|
378
|
+
// Defensive: confirm the 503 path also carries a suggestion (some
|
|
379
|
+
// consumers — including the structured-error path in useChat.ts —
|
|
380
|
+
// branch on `body.code === 'plugin_offline'`).
|
|
381
|
+
moveServeJsonAside();
|
|
382
|
+
try {
|
|
383
|
+
const res = await fetch(
|
|
384
|
+
`${dashboardBaseUrl}/api/opencode-sessions/sess-1/messages`,
|
|
385
|
+
);
|
|
386
|
+
assert.equal(res.status, 503);
|
|
387
|
+
const body = await res.json();
|
|
388
|
+
assert.equal(body.error, 'plugin_offline');
|
|
389
|
+
assert.ok(typeof body.suggestion === 'string');
|
|
390
|
+
assert.ok(body.suggestion.length > 0);
|
|
391
|
+
assert.match(body.suggestion, /bizar doctor|opencode serve/i);
|
|
392
|
+
} finally {
|
|
393
|
+
writeServeJson('/tmp/session-worktree');
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test('GET messages 503 directory_unknown envelope includes suggestion', async () => {
|
|
398
|
+
writeServeJsonWithoutWorktree();
|
|
399
|
+
try {
|
|
400
|
+
const res = await fetch(
|
|
401
|
+
`${dashboardBaseUrl}/api/opencode-sessions/no-such-session/messages`,
|
|
402
|
+
);
|
|
403
|
+
assert.equal(res.status, 503);
|
|
404
|
+
const body = await res.json();
|
|
405
|
+
assert.equal(body.error, 'directory_unknown');
|
|
406
|
+
assert.ok(typeof body.message === 'string');
|
|
407
|
+
assert.ok(typeof body.suggestion === 'string');
|
|
408
|
+
assert.ok(body.suggestion.length > 0);
|
|
409
|
+
} finally {
|
|
410
|
+
writeServeJson('/tmp/session-worktree');
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test('GET messages uses worktree fast-path when session exists in recorded worktree', async () => {
|
|
415
|
+
// The new resolveSessionDirectory fast-path probes the recorded
|
|
416
|
+
// worktree first (cheap GET /api/session/{id}?directory=... probe).
|
|
417
|
+
// When the upstream says yes, the resolver returns the worktree
|
|
418
|
+
// WITHOUT calling GET /api/session. We assert the latter is NOT
|
|
419
|
+
// called by checking the upstream.promptHits / request log isn't
|
|
420
|
+
// touched (only the messages endpoint should fire here).
|
|
421
|
+
//
|
|
422
|
+
// Indirect check: request a session whose id is NOT in the
|
|
423
|
+
// /api/session listing but IS in /tmp/session-worktree per the
|
|
424
|
+
// probe. The probe handler returns 200 for that directory, so the
|
|
425
|
+
// route should succeed even though the session isn't in the list.
|
|
426
|
+
writeServeJson('/tmp/session-worktree');
|
|
427
|
+
const res = await fetch(
|
|
428
|
+
`${dashboardBaseUrl}/api/opencode-sessions/sess-1/messages`,
|
|
429
|
+
);
|
|
430
|
+
assert.equal(res.status, 200);
|
|
431
|
+
const body = await res.json();
|
|
432
|
+
assert.ok(Array.isArray(body.messages));
|
|
433
|
+
assert.ok(body.messages.length >= 1);
|
|
276
434
|
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/routes-doctor.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* v6.0.0 — Tests for the Doctor REST surface:
|
|
5
|
+
*
|
|
6
|
+
* GET /api/doctor — full snapshot
|
|
7
|
+
* GET /api/doctor/health — rolled-up status + groups
|
|
8
|
+
* POST /api/doctor/check — single-check dispatch
|
|
9
|
+
*
|
|
10
|
+
* We stand up a minimal express server backed by the real
|
|
11
|
+
* `routes/doctor.mjs` factory. The store reads `~/.config/bizar/`
|
|
12
|
+
* on the host, so the tests must not require a pristine install —
|
|
13
|
+
* any I/O failure path is handled by the store (returns [] / 'warn')
|
|
14
|
+
* and the test assertions stay loose enough to pass on dev machines
|
|
15
|
+
* AND on CI with no bizar installed.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, it, before, after } from 'node:test';
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import express from 'express';
|
|
20
|
+
|
|
21
|
+
const DOCTOR_ROUTES = await import('../src/server/routes/doctor.mjs');
|
|
22
|
+
|
|
23
|
+
let server;
|
|
24
|
+
let baseUrl;
|
|
25
|
+
|
|
26
|
+
before(async () => {
|
|
27
|
+
const router = DOCTOR_ROUTES.createDoctorRouter();
|
|
28
|
+
const app = express();
|
|
29
|
+
app.use(express.json({ limit: '1mb' }));
|
|
30
|
+
app.use('/api/doctor', router);
|
|
31
|
+
await new Promise((resolve) => {
|
|
32
|
+
server = app.listen(0, () => {
|
|
33
|
+
const addr = server.address();
|
|
34
|
+
baseUrl = `http://127.0.0.1:${addr.port}`;
|
|
35
|
+
resolve();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
after(() => {
|
|
41
|
+
try { server?.close?.(); } catch { /* ignore */ }
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// ── GET /api/doctor ────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
describe('GET /api/doctor', () => {
|
|
47
|
+
it('returns 200 + a snapshot with the documented top-level keys', async () => {
|
|
48
|
+
const r = await fetch(`${baseUrl}/api/doctor/`);
|
|
49
|
+
assert.strictEqual(r.status, 200);
|
|
50
|
+
const body = await r.json();
|
|
51
|
+
for (const key of [
|
|
52
|
+
'timestamp', 'bizarVersion', 'nodeVersion', 'platform', 'arch',
|
|
53
|
+
'uptime', 'memory', 'disk', 'services', 'counts',
|
|
54
|
+
'recentErrors', 'configHealth', 'opencode', 'checks', 'health',
|
|
55
|
+
]) {
|
|
56
|
+
assert.ok(key in body, `expected ${key} in snapshot`);
|
|
57
|
+
}
|
|
58
|
+
assert.strictEqual(body.nodeVersion, process.version);
|
|
59
|
+
assert.ok(['ok', 'warn', 'fail'].includes(body.health.status));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('counts include all expected numeric fields', async () => {
|
|
63
|
+
const r = await fetch(`${baseUrl}/api/doctor/`);
|
|
64
|
+
const body = await r.json();
|
|
65
|
+
for (const key of [
|
|
66
|
+
'tasks', 'schedules', 'mods', 'providers', 'mcps', 'agents',
|
|
67
|
+
'projects', 'workspaces', 'voiceNotes', 'evalRuns', 'backups',
|
|
68
|
+
]) {
|
|
69
|
+
assert.strictEqual(typeof body.counts[key], 'number', `counts.${key}`);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('health.issues is an array', async () => {
|
|
74
|
+
const r = await fetch(`${baseUrl}/api/doctor/`);
|
|
75
|
+
const body = await r.json();
|
|
76
|
+
assert.ok(Array.isArray(body.health.issues));
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ── GET /api/doctor/health ─────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
describe('GET /api/doctor/health', () => {
|
|
83
|
+
it('returns 200 + rolled-up health with groups', async () => {
|
|
84
|
+
const r = await fetch(`${baseUrl}/api/doctor/health`);
|
|
85
|
+
assert.strictEqual(r.status, 200);
|
|
86
|
+
const body = await r.json();
|
|
87
|
+
assert.strictEqual(typeof body.ts, 'string');
|
|
88
|
+
assert.ok(['ok', 'warn', 'fail'].includes(body.status));
|
|
89
|
+
assert.ok(Array.isArray(body.issues));
|
|
90
|
+
assert.ok(body.groups);
|
|
91
|
+
assert.ok(Array.isArray(body.groups.system));
|
|
92
|
+
assert.ok(Array.isArray(body.groups.config));
|
|
93
|
+
assert.ok(Array.isArray(body.groups.services));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('derives status from issues', async () => {
|
|
97
|
+
const r = await fetch(`${baseUrl}/api/doctor/health`);
|
|
98
|
+
const body = await r.json();
|
|
99
|
+
const has = (s) => body.issues.some((i) => i.status === s);
|
|
100
|
+
if (has('fail')) assert.strictEqual(body.status, 'fail');
|
|
101
|
+
else if (has('warn')) assert.strictEqual(body.status, 'warn');
|
|
102
|
+
else assert.strictEqual(body.status, 'ok');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ── POST /api/doctor/check ─────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
describe('POST /api/doctor/check', () => {
|
|
109
|
+
it('returns 200 + the check for a known name', async () => {
|
|
110
|
+
const r = await fetch(`${baseUrl}/api/doctor/check`, {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: { 'content-type': 'application/json' },
|
|
113
|
+
body: JSON.stringify({ checkName: 'node' }),
|
|
114
|
+
});
|
|
115
|
+
assert.strictEqual(r.status, 200);
|
|
116
|
+
const body = await r.json();
|
|
117
|
+
assert.strictEqual(body.name, 'node');
|
|
118
|
+
assert.ok(['ok', 'warn', 'fail'].includes(body.status));
|
|
119
|
+
assert.strictEqual(typeof body.message, 'string');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('returns 404 for an unknown check name', async () => {
|
|
123
|
+
const r = await fetch(`${baseUrl}/api/doctor/check`, {
|
|
124
|
+
method: 'POST',
|
|
125
|
+
headers: { 'content-type': 'application/json' },
|
|
126
|
+
body: JSON.stringify({ checkName: 'definitely-not-a-real-check' }),
|
|
127
|
+
});
|
|
128
|
+
assert.strictEqual(r.status, 404);
|
|
129
|
+
const body = await r.json();
|
|
130
|
+
assert.strictEqual(body.status, 'fail');
|
|
131
|
+
assert.strictEqual(body.message, 'unknown check');
|
|
132
|
+
assert.match(body.error, /Available: /);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('returns 400 when checkName is missing', async () => {
|
|
136
|
+
const r = await fetch(`${baseUrl}/api/doctor/check`, {
|
|
137
|
+
method: 'POST',
|
|
138
|
+
headers: { 'content-type': 'application/json' },
|
|
139
|
+
body: JSON.stringify({}),
|
|
140
|
+
});
|
|
141
|
+
assert.strictEqual(r.status, 400);
|
|
142
|
+
const body = await r.json();
|
|
143
|
+
assert.strictEqual(body.status, 'fail');
|
|
144
|
+
assert.match(body.message, /checkName required/);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('accepts multiple known check names', async () => {
|
|
148
|
+
for (const checkName of ['memory', 'opencode-config', 'dashboard']) {
|
|
149
|
+
const r = await fetch(`${baseUrl}/api/doctor/check`, {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
headers: { 'content-type': 'application/json' },
|
|
152
|
+
body: JSON.stringify({ checkName }),
|
|
153
|
+
});
|
|
154
|
+
assert.strictEqual(r.status, 200);
|
|
155
|
+
const body = await r.json();
|
|
156
|
+
assert.strictEqual(body.name, checkName);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
});
|