beadboard/tests/components/graph/graph-node-conversation.test.tsx
ZenchantLive 861ae89491 fix: wire conversation panel to DAG nodes with toggle support
- Add MessageSquare icon to GraphNodeCard; prop-thread onConversationOpen
  and selectedTaskId through WorkflowGraph node data (no useUrlState
  inside ReactFlow nodes — avoids context/timing issues)
- Fix ContextualRightPanel: check taskId before epicId so clicking the
  conversation icon always opens ThreadDrawer even when an epic filter
  is active
- setEpicId now clears task from URL so selecting an epic resets any
  open conversation thread
- handleGraphSelect toggles: second click on same node calls setTaskId(null)
  closing the right panel
- Add onSelect to WorkflowGraph flowModel deps to prevent stale callbacks
- Fix ContextualRightPanel onClose no-ops: wired to setTaskId(null) /
  setSwarmId(null) so back button works
- Right panel always visible (removed panel==='open' gate in UnifiedShell)
- SmartDag task grid: horizontal scroll, fixed-width cards, hideClosed=true
- Add <Suspense> in page.tsx for useSearchParams compatibility
- Enable dolt auto-start in .beads/config.yaml
- Add 14 static analysis tests (graph-node-conversation.test.tsx)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 13:51:46 -08:00

133 lines
6 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'fs/promises';
import path from 'path';
const NODE_CARD = path.join(process.cwd(), 'src/components/graph/graph-node-card.tsx');
const RIGHT_PANEL = path.join(process.cwd(), 'src/components/activity/contextual-right-panel.tsx');
const SHELL = path.join(process.cwd(), 'src/components/shared/unified-shell.tsx');
const WORKFLOW_GRAPH = path.join(process.cwd(), 'src/components/shared/workflow-graph.tsx');
const PAGE = path.join(process.cwd(), 'src/app/page.tsx');
// ── GraphNodeCard: conversation icon ────────────────────────────────────────
test('GraphNodeCard - has MessageSquare conversation icon', async () => {
const src = await fs.readFile(NODE_CARD, 'utf-8');
assert.ok(src.includes('MessageSquare'), 'must import and render MessageSquare');
});
test('GraphNodeCard - does NOT use useUrlState (ReactFlow context/timing issues)', async () => {
const src = await fs.readFile(NODE_CARD, 'utf-8');
assert.ok(
!src.includes('useUrlState'),
'GraphNodeCard must NOT call useUrlState — hooks inside ReactFlow node renderers have context issues; use prop-threading through data instead'
);
});
test('GraphNodeCard - reads onConversationOpen callback from node data', async () => {
const src = await fs.readFile(NODE_CARD, 'utf-8');
assert.ok(
src.includes('onConversationOpen'),
'GraphNodeCard must read onConversationOpen from data (not from hooks)'
);
assert.ok(
src.includes('onConversationOpen?.(id)') || src.includes('onConversationOpen(id)'),
'onConversationOpen must be called with the node id on icon click'
);
});
test('GraphNodeCard - stops event propagation on icon click', async () => {
const src = await fs.readFile(NODE_CARD, 'utf-8');
assert.ok(
src.includes('stopPropagation'),
'must call e.stopPropagation() to prevent ReactFlow from swallowing the click'
);
});
test('GraphNodeCard - highlights icon when selectedTaskId matches this node', async () => {
const src = await fs.readFile(NODE_CARD, 'utf-8');
assert.ok(
src.includes('selectedTaskId') || src.includes('isConvOpen'),
'must use selectedTaskId from data for icon highlight, not local URL state'
);
});
// ── WorkflowGraph: threads callbacks into node data ─────────────────────────
test('WorkflowGraph - passes onConversationOpen into node data', async () => {
const src = await fs.readFile(WORKFLOW_GRAPH, 'utf-8');
assert.ok(
src.includes('onConversationOpen'),
'WorkflowGraph must pass onConversationOpen into node data so GraphNodeCard can call it without hooks'
);
});
test('WorkflowGraph - passes selectedTaskId into node data for icon highlight', async () => {
const src = await fs.readFile(WORKFLOW_GRAPH, 'utf-8');
assert.ok(
src.includes('selectedTaskId'),
'WorkflowGraph must pass selectedTaskId into node data so GraphNodeCard can highlight the active conversation icon'
);
});
// ── ContextualRightPanel: back button wired ──────────────────────────────────
test('ContextualRightPanel - task branch onClose is NOT a no-op', async () => {
const src = await fs.readFile(RIGHT_PANEL, 'utf-8');
const hasNoOp = /onClose=\{.*\(\)\s*=>\s*\{\s*\}\s*\}/.test(src);
assert.ok(
!hasNoOp,
'onClose must not be a no-op () => {} — the back button in ThreadDrawer would do nothing'
);
});
test('ContextualRightPanel - task branch onClose clears taskId', async () => {
const src = await fs.readFile(RIGHT_PANEL, 'utf-8');
assert.ok(
src.includes('setTaskId(null)') || src.includes('clearSelection'),
'onClose must call setTaskId(null) so the back button navigates back to the activity feed'
);
});
test('ContextualRightPanel - swarm branch onClose clears swarmId', async () => {
const src = await fs.readFile(RIGHT_PANEL, 'utf-8');
assert.ok(
src.includes('setSwarmId(null)') || src.includes('clearSelection'),
'SwarmIdBranch onClose must call setSwarmId(null)'
);
});
test('ContextualRightPanel - taskId if-branch appears before epicId if-branch', async () => {
const src = await fs.readFile(RIGHT_PANEL, 'utf-8');
const taskIfIdx = src.indexOf('if (taskId)');
const epicIfIdx = src.indexOf('if (epicId)');
assert.ok(taskIfIdx !== -1, 'must have an if (taskId) branch');
assert.ok(epicIfIdx !== -1, 'must have an if (epicId) branch');
assert.ok(
taskIfIdx < epicIfIdx,
'if (taskId) check must come before if (epicId) check — task conversation takes priority over epic feed when user clicks conversation icon in graph'
);
});
// ── UnifiedShell: right panel always visible ─────────────────────────────────
test('UnifiedShell - right panel NOT gated behind panel === open', async () => {
const src = await fs.readFile(SHELL, 'utf-8');
const hasGate = /panel\s*===\s*['"]open['"]\s*&&\s*[\s\S]{0,60}<div[^>]*rightWidth/.test(src);
assert.ok(!hasGate, 'Right panel div must render unconditionally');
});
test('UnifiedShell - passes taskId to ContextualRightPanel', async () => {
const src = await fs.readFile(SHELL, 'utf-8');
assert.ok(src.includes('taskId={taskId}'), 'must pass taskId so right panel shows conversation on selection');
});
// ── page.tsx: Suspense boundary for useSearchParams ──────────────────────────
test('page.tsx - wraps UnifiedShell in Suspense for useSearchParams', async () => {
const src = await fs.readFile(PAGE, 'utf-8');
assert.ok(
src.includes('Suspense'),
'page.tsx must wrap UnifiedShell in <Suspense> — without it, useSearchParams updates from deep components (like inside ReactFlow nodes) may not propagate correctly'
);
});