Adds a new grouping system for the Map with a new grouping depth control, exposes per-module dependents (files and modules) to drive a weight bar, and renders it on each module. Introduces a MapKey to explain visuals, collapses lone root-file buckets for clearer labeling, and supports a nullable depth value to let the provider pick grouping. The Symbol tab now has its own address (#/s) when nothing is selected, and routing/top-bar logic is updated accordingly. Also updates export SVG rendering to include weight-based bars, and extends tests and docs to cover the new visuals and behavior.
787 lines
25 KiB
TypeScript
787 lines
25 KiB
TypeScript
/**
|
|
* `@colbymchenry/codegraph-ui` — the package's own test (task CG-61).
|
|
*
|
|
* A minimal Svelte host mounts the three headline components from the package
|
|
* entry against a MOCK adapter and asserts what lands in the document. That is
|
|
* the whole promise of the package in one file: CodeGraph Pro renders these
|
|
* same components over its own in-process engine reads, so if a screen can be
|
|
* drawn from an object literal here, it can be drawn from a graph there.
|
|
*
|
|
* The import is `ui/src/index.ts` — the package entry itself, not the
|
|
* components one by one — so a name dropped from the public surface fails here
|
|
* rather than in the Pro app.
|
|
*
|
|
* Everything below is deliberately about the SEAM, not about the screens:
|
|
* layout, geometry and the rails have their own suites (`ui-symbol-model`,
|
|
* `ui-flow-model`, `ui-map-model`). What is being proved here is that no
|
|
* component reaches past the adapter for anything.
|
|
*/
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { flushSync, mount, unmount } from 'svelte';
|
|
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
ArchitectureMap,
|
|
CodegraphUi,
|
|
FlowStrip,
|
|
SearchPalette,
|
|
SymbolView,
|
|
SavedTrails,
|
|
TrailBar,
|
|
TypeHierarchy,
|
|
createHttpAdapter,
|
|
fileHref,
|
|
flowHref,
|
|
getGraphAdapter,
|
|
hashNavigation,
|
|
live,
|
|
mapHref,
|
|
setGraphAdapter,
|
|
setNavigationDriver,
|
|
symbolHref,
|
|
trail,
|
|
type GraphAdapter,
|
|
type NavigationDriver,
|
|
type WireFlowPayload,
|
|
type WireMapPayload,
|
|
type WireNodeRef,
|
|
type WireSource,
|
|
type WireStats,
|
|
type WireHierarchy,
|
|
type WireSymbolPayload,
|
|
} from '../ui/src/index';
|
|
|
|
/* ---------------------------------------------------------------- fixtures */
|
|
|
|
const ROOT = join(import.meta.dirname, '..');
|
|
|
|
function nodeRef(overrides: Partial<WireNodeRef> = {}): WireNodeRef {
|
|
return {
|
|
id: 'function:parseToken@src/auth/token.ts:12',
|
|
kind: 'function',
|
|
name: 'parseToken',
|
|
qualifiedName: 'parseToken',
|
|
file: 'src/auth/token.ts',
|
|
line: 12,
|
|
endLine: 18,
|
|
language: 'typescript',
|
|
test: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const CALLER = nodeRef({
|
|
id: 'function:handleCallback@src/auth/callback.ts:40',
|
|
name: 'handleCallback',
|
|
qualifiedName: 'handleCallback',
|
|
file: 'src/auth/callback.ts',
|
|
line: 40,
|
|
endLine: 60,
|
|
});
|
|
|
|
const CALLEE = nodeRef({
|
|
id: 'function:decodeJwt@src/auth/jwt.ts:3',
|
|
name: 'decodeJwt',
|
|
qualifiedName: 'decodeJwt',
|
|
file: 'src/auth/jwt.ts',
|
|
line: 3,
|
|
endLine: 9,
|
|
});
|
|
|
|
const SYMBOL: WireSymbolPayload = {
|
|
node: {
|
|
...nodeRef(),
|
|
startColumn: 0,
|
|
endColumn: 1,
|
|
lines: 7,
|
|
exported: true,
|
|
},
|
|
ancestors: [nodeRef({ id: 'file:src/auth/token.ts', kind: 'file', name: 'token.ts' })],
|
|
members: { total: 0, shown: 0, truncated: false, items: [] },
|
|
incoming: {
|
|
total: 1,
|
|
shown: 1,
|
|
truncated: false,
|
|
items: [
|
|
{
|
|
node: CALLER,
|
|
edgeKinds: ['calls'],
|
|
edges: [{ kind: 'calls', line: 44, col: 6, confidence: 1 }],
|
|
edgeCount: 1,
|
|
lines: [44],
|
|
confidence: 1,
|
|
uncertain: false,
|
|
synthesized: false,
|
|
},
|
|
],
|
|
},
|
|
outgoing: {
|
|
total: 1,
|
|
shown: 1,
|
|
truncated: false,
|
|
items: [
|
|
{
|
|
node: CALLEE,
|
|
edgeKinds: ['calls'],
|
|
edges: [{ kind: 'calls', line: 14, col: 10, confidence: 1 }],
|
|
edgeCount: 1,
|
|
lines: [14],
|
|
confidence: 1,
|
|
uncertain: false,
|
|
synthesized: false,
|
|
},
|
|
],
|
|
},
|
|
typesUsed: [],
|
|
hierarchy: null,
|
|
counts: { callers: 1, callees: 1, typesUsed: 0, fanIn: 1, fanOut: 1, members: 0, hub: false },
|
|
tests: { reached: false, hops: null, fileCount: 0, files: [], exhaustive: true, hopsSearched: 3 },
|
|
outsideIndex: { total: 0, byKind: {}, samples: [] },
|
|
blast: {
|
|
direct: 1,
|
|
withinHops: 2,
|
|
hops: 3,
|
|
files: 2,
|
|
testFiles: 0,
|
|
routes: 0,
|
|
topFiles: [{ file: 'src/auth/callback.ts', symbols: 1, test: false }],
|
|
},
|
|
drift: false,
|
|
};
|
|
|
|
const SOURCE_LINES = [
|
|
'export function parseToken(raw: string): Token {',
|
|
' // Normalize expiry before anything else reads it.',
|
|
' const claims = decodeJwt(raw);',
|
|
' return { ...claims, expiresAt: claims.exp * 1000 };',
|
|
'}',
|
|
];
|
|
|
|
const SOURCE: WireSource = {
|
|
file: 'src/auth/token.ts',
|
|
language: 'typescript',
|
|
drift: false,
|
|
showing: 'indexed',
|
|
contentHash: 'abc123',
|
|
indexedAt: 1_700_000_000_000,
|
|
generated: false,
|
|
totalLines: 40,
|
|
from: 12,
|
|
to: 18,
|
|
lines: SOURCE_LINES,
|
|
};
|
|
|
|
const FLOW: WireFlowPayload = {
|
|
query: { kind: 'directed', from: 'handleCallback', to: 'decodeJwt', symbols: [] },
|
|
flows: [
|
|
{
|
|
id: 'flow-1',
|
|
label: 'handleCallback → decodeJwt',
|
|
partial: false,
|
|
boundary: null,
|
|
hops: [
|
|
{
|
|
node: CALLER,
|
|
edge: null,
|
|
callRef: { line: 44, col: 6, name: 'parseToken', targetId: SYMBOL.node.id, backwards: false },
|
|
source: {
|
|
file: 'src/auth/callback.ts',
|
|
language: 'typescript',
|
|
from: 44,
|
|
to: 46,
|
|
lines: [' const token = parseToken(raw);'],
|
|
drift: false,
|
|
},
|
|
},
|
|
{
|
|
node: nodeRef(),
|
|
edge: {
|
|
kind: 'calls',
|
|
line: 44,
|
|
label: 'calls',
|
|
upward: false,
|
|
uncertain: false,
|
|
synthesized: false,
|
|
},
|
|
callRef: null,
|
|
source: {
|
|
file: 'src/auth/token.ts',
|
|
language: 'typescript',
|
|
from: 12,
|
|
to: 14,
|
|
lines: SOURCE_LINES.slice(0, 3),
|
|
drift: false,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
ambiguous: [],
|
|
unresolved: [],
|
|
reason: null,
|
|
index: { lastIndexedAt: 1_700_000_000_000, edges: 4, files: 3 },
|
|
timing: { elapsedMs: 2 },
|
|
};
|
|
|
|
const MAP: WireMapPayload = {
|
|
root: 'src',
|
|
depth: 1,
|
|
roots: [{ root: 'src', label: 'src', files: 3 }],
|
|
modules: [
|
|
{
|
|
id: 'src/auth',
|
|
label: 'auth',
|
|
files: 2,
|
|
symbols: 6,
|
|
languages: [{ language: 'typescript', files: 2 }],
|
|
test: false,
|
|
facade: false,
|
|
fileList: { total: 2, shown: 2, truncated: false, items: ['src/auth/token.ts', 'src/auth/callback.ts'] },
|
|
},
|
|
{
|
|
id: 'src/http',
|
|
label: 'http',
|
|
files: 1,
|
|
symbols: 3,
|
|
languages: [{ language: 'typescript', files: 1 }],
|
|
test: false,
|
|
facade: false,
|
|
fileList: { total: 1, shown: 1, truncated: false, items: ['src/http/server.ts'] },
|
|
},
|
|
],
|
|
links: [
|
|
{
|
|
source: 'src/http',
|
|
target: 'src/auth',
|
|
count: 9,
|
|
declared: 7,
|
|
byKind: [{ kind: 'calls', count: 9 }],
|
|
topPairs: [{ from: 'src/http/server.ts', to: 'src/auth/token.ts', count: 9, declared: 7 }],
|
|
},
|
|
],
|
|
cycles: { total: 0, shown: 0, truncated: false, items: [] },
|
|
excluded: { uncertainEdges: 0, confidenceBelow: 0.6 },
|
|
index: { lastIndexedAt: 1_700_000_000_000, edges: 9, files: 3 },
|
|
timing: { elapsedMs: 1, cached: false },
|
|
};
|
|
|
|
const STATS: WireStats = {
|
|
project: { root: '/tmp/demo', name: 'demo' },
|
|
index: {
|
|
state: 'ready',
|
|
lastIndexedAt: 1_700_000_000_000,
|
|
stale: false,
|
|
version: '1.0.0',
|
|
extractionVersion: 1,
|
|
backend: 'node-sqlite',
|
|
journalMode: 'wal',
|
|
pendingReferences: 0,
|
|
generatedFiles: 0,
|
|
watching: false,
|
|
watcherDegraded: false,
|
|
},
|
|
graph: {
|
|
nodes: 9,
|
|
edges: 9,
|
|
files: 3,
|
|
nodesByKind: { function: 9 },
|
|
edgesByKind: { calls: 9 },
|
|
filesByLanguage: { typescript: 3 },
|
|
dbSizeBytes: 1024,
|
|
walSizeBytes: 0,
|
|
},
|
|
frameworks: [],
|
|
thresholds: { hub: 40, uncertainBelow: 0.6 },
|
|
blastScale: { maxDirect: 20, maxWithinHops: 60, hops: 3, sampled: 24, estimated: true },
|
|
};
|
|
|
|
/* ------------------------------------------------------------ mock adapter */
|
|
|
|
/** Every method the components can reach, and a record of which ones they did. */
|
|
function mockAdapter(): { adapter: GraphAdapter; calls: string[] } {
|
|
const calls: string[] = [];
|
|
const seen = <T>(name: string, value: T): Promise<T> => {
|
|
calls.push(name);
|
|
return Promise.resolve(value);
|
|
};
|
|
const adapter: GraphAdapter = {
|
|
stats: () => seen('stats', STATS),
|
|
search: () =>
|
|
seen('search', {
|
|
query: '',
|
|
text: '',
|
|
filters: { kinds: [], languages: [], paths: [], names: [] },
|
|
results: { total: 0, shown: 0, truncated: false, items: [] },
|
|
groups: [],
|
|
}),
|
|
node: (id) => {
|
|
calls.push(`node:${id}`);
|
|
return Promise.resolve(SYMBOL);
|
|
},
|
|
nodes: () => seen('nodes', { items: [], missing: [] }),
|
|
source: (request) => {
|
|
calls.push(`source:${request.file}`);
|
|
return Promise.resolve(SOURCE);
|
|
},
|
|
file: () =>
|
|
seen('file', {
|
|
file: {
|
|
path: 'src/auth/token.ts',
|
|
language: 'typescript',
|
|
size: 900,
|
|
modifiedAt: 0,
|
|
indexedAt: 0,
|
|
contentHash: 'abc123',
|
|
nodeCount: 3,
|
|
generated: false,
|
|
test: false,
|
|
errors: [],
|
|
id: 'file:src/auth/token.ts',
|
|
},
|
|
topLevel: { calls: 0 },
|
|
drift: false,
|
|
outline: { total: 0, shown: 0, truncated: false, items: [] },
|
|
imports: { total: 0, shown: 0, truncated: false, items: [] },
|
|
importedBy: { total: 0, shown: 0, truncated: false, items: [] },
|
|
unresolvedImports: [],
|
|
dependencies: [],
|
|
dependents: [],
|
|
}),
|
|
fileCode: () =>
|
|
seen('fileCode', {
|
|
file: {
|
|
path: 'src/auth/token.ts',
|
|
language: 'typescript',
|
|
size: 900,
|
|
indexedAt: 0,
|
|
contentHash: 'abc123',
|
|
generated: false,
|
|
test: false,
|
|
errors: [],
|
|
id: 'file:src/auth/token.ts',
|
|
totalLines: 40,
|
|
},
|
|
drift: false,
|
|
outline: { total: 0, shown: 0, truncated: false, items: [] },
|
|
calls: { total: 0, shown: 0, truncated: false, items: [] },
|
|
outside: { total: 0, shown: 0, truncated: false, items: [] },
|
|
intraFileCalls: 0,
|
|
timing: { elapsedMs: 1 },
|
|
}),
|
|
flow: () => seen('flow', FLOW),
|
|
map: () => seen('map', MAP),
|
|
routes: () =>
|
|
seen('routes', {
|
|
routed: false,
|
|
routeCount: 0,
|
|
shown: 0,
|
|
truncated: false,
|
|
topHandlerFile: null,
|
|
topHandlerFileCount: 0,
|
|
entries: [],
|
|
}),
|
|
entryPoints: () =>
|
|
seen('entryPoints', {
|
|
frameworks: [],
|
|
routes: { routed: false, routeCount: 0, items: { total: 0, shown: 0, truncated: false, items: [] } },
|
|
files: { total: 0, shown: 0, truncated: false, items: [] },
|
|
tests: { total: 0, shown: 0, truncated: false, items: [] },
|
|
hubs: { total: 0, shown: 0, truncated: false, items: [] },
|
|
index: { lastIndexedAt: null, files: 3 },
|
|
timing: { elapsedMs: 1, cached: false },
|
|
}),
|
|
deadCode: () =>
|
|
seen('deadCode', {
|
|
rows: { total: 0, shown: 0, truncated: false, items: [] },
|
|
groups: [],
|
|
candidates: 0,
|
|
excluded: [],
|
|
excludedTotal: 0,
|
|
kinds: ['function'],
|
|
includeExported: false,
|
|
includeTests: false,
|
|
includeGenerated: false,
|
|
bounded: false,
|
|
corroborated: true,
|
|
timing: { elapsedMs: 1 },
|
|
}),
|
|
trails: () =>
|
|
seen('trails', {
|
|
trails: [],
|
|
// A host with nowhere to keep trails still ANSWERS the question — it
|
|
// says it is read-only rather than omitting the method, so the screens
|
|
// show the section explained instead of showing a Save that does
|
|
// nothing.
|
|
readOnly: true,
|
|
readOnlyReason: 'This host does not store trails.',
|
|
directory: '.codegraph/ui/trails',
|
|
skipped: 0,
|
|
bounded: false,
|
|
}),
|
|
// Deliberately no `events`, `saveTrail` or `deleteTrail`: a host without a
|
|
// live channel and without anywhere to write is the normal case, and
|
|
// nothing may poll or offer to save in their absence.
|
|
};
|
|
return { adapter, calls };
|
|
}
|
|
|
|
/* ----------------------------------------------------------------- harness */
|
|
|
|
let host: HTMLDivElement;
|
|
let mounted: Record<string, unknown> | null = null;
|
|
|
|
/** jsdom has none of the observers a canvas library expects. */
|
|
beforeAll(() => {
|
|
class NoopObserver {
|
|
observe(): void {}
|
|
unobserve(): void {}
|
|
disconnect(): void {}
|
|
}
|
|
const globals = globalThis as Record<string, unknown>;
|
|
globals.ResizeObserver ??= NoopObserver;
|
|
globals.IntersectionObserver ??= NoopObserver;
|
|
globals.MutationObserver ??= NoopObserver;
|
|
globals.requestAnimationFrame ??= (fn: FrameRequestCallback) =>
|
|
setTimeout(() => fn(0), 0) as unknown as number;
|
|
globals.cancelAnimationFrame ??= (handle: number) => clearTimeout(handle);
|
|
// jsdom's own `matchMedia` is a stub that is not callable here, and Svelte's
|
|
// `MediaQuery` (which `@xyflow/svelte`'s store constructs eagerly) calls it
|
|
// the moment a canvas mounts. Replace it outright rather than guarding.
|
|
const media = (query: string) => ({
|
|
media: query,
|
|
matches: false,
|
|
onchange: null,
|
|
addEventListener() {},
|
|
removeEventListener() {},
|
|
addListener() {},
|
|
removeListener() {},
|
|
dispatchEvent: () => false,
|
|
});
|
|
Object.defineProperty(window, 'matchMedia', { configurable: true, writable: true, value: media });
|
|
globals.matchMedia = media;
|
|
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {};
|
|
});
|
|
|
|
beforeEach(() => {
|
|
host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
trail.clear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (mounted) {
|
|
void unmount(mounted);
|
|
mounted = null;
|
|
}
|
|
host.remove();
|
|
setGraphAdapter(null);
|
|
setNavigationDriver(null);
|
|
});
|
|
|
|
/**
|
|
* Mount a component and let its data effects settle.
|
|
*
|
|
* Every screen fetches inside an `$effect`, so a render is not finished until
|
|
* the promise the adapter returned has resolved and the follow-up render has
|
|
* flushed. Two macrotask turns cover the deepest chain any of them has (the
|
|
* Symbol view: node, then its source).
|
|
*/
|
|
async function render(
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
component: any,
|
|
props: Record<string, unknown>
|
|
): Promise<void> {
|
|
mounted = mount(component, { target: host, props }) as Record<string, unknown>;
|
|
for (let turn = 0; turn < 4; turn += 1) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
flushSync();
|
|
}
|
|
}
|
|
|
|
describe('@colbymchenry/codegraph-ui — a host renders the package', () => {
|
|
it('SymbolView draws callers, source and the callee rail from a mock adapter', async () => {
|
|
const { adapter, calls } = mockAdapter();
|
|
setGraphAdapter(adapter);
|
|
|
|
await render(SymbolView, { id: SYMBOL.node.id, line: null });
|
|
|
|
// It asked the adapter, by id, and it asked for the symbol's own slice.
|
|
expect(calls).toContain(`node:${SYMBOL.node.id}`);
|
|
expect(calls).toContain('source:src/auth/token.ts');
|
|
|
|
const text = host.textContent ?? '';
|
|
expect(text).toContain('parseToken');
|
|
// The caller rail (left) and the callee rail (right) are both drawn.
|
|
expect(text).toContain('handleCallback');
|
|
expect(text).toContain('decodeJwt');
|
|
// The verbatim source, not a summary of it.
|
|
expect(text).toContain('expiresAt');
|
|
// The honesty badge: nothing in the fixture's graph tests this symbol.
|
|
expect(text.toLowerCase()).toContain('test');
|
|
});
|
|
|
|
it('TypeHierarchy draws the fan, its wiring and its fold from a payload alone', async () => {
|
|
const implementers = Array.from({ length: 14 }, (_, i) => ({
|
|
id: `impl-${i}`,
|
|
kind: 'class' as const,
|
|
name: `Target${i}`,
|
|
qualifiedName: `Target${i}`,
|
|
file: `src/targets/target-${i}.ts`,
|
|
line: 1,
|
|
endLine: 9,
|
|
language: 'typescript' as const,
|
|
test: false,
|
|
depth: 1,
|
|
parentId: SYMBOL.node.id,
|
|
relation: 'implements' as const,
|
|
// The first one arrived through a resolver rather than a parse, which is
|
|
// the case the block has to draw differently.
|
|
synthesized: i === 0,
|
|
...(i === 0 ? { via: 'go-implements', registeredAt: 'src/clock.go:11' } : {}),
|
|
hiddenSubtypes: 0,
|
|
}));
|
|
const hierarchy: WireHierarchy = {
|
|
ancestors: { total: 0, shown: 0, truncated: false, items: [] },
|
|
descendants: {
|
|
total: implementers.length,
|
|
shown: implementers.length,
|
|
truncated: false,
|
|
items: implementers,
|
|
},
|
|
direct: implementers.length,
|
|
implementers: implementers.length,
|
|
bounded: false,
|
|
polymorphic: true,
|
|
};
|
|
|
|
await render(TypeHierarchy, { hierarchy, focus: SYMBOL.node, onopen: () => {} });
|
|
|
|
const text = host.textContent ?? '';
|
|
// The claim a reader cannot get by counting rows.
|
|
expect(text).toContain('14 implementations');
|
|
// The wiring site of the synthesized edge.
|
|
expect(text).toContain('go-implements');
|
|
// Twelve rows, then the fold — never a silent truncation.
|
|
expect(text).toContain('+2 more implementations');
|
|
expect(text).toContain('Target0');
|
|
expect(text).not.toContain('Target13');
|
|
// It draws no network of its own: this component was handed a payload.
|
|
expect(host.querySelectorAll('path').length).toBe(12);
|
|
});
|
|
|
|
it('FlowStrip draws one card per hop from a mock adapter', async () => {
|
|
const { adapter, calls } = mockAdapter();
|
|
setGraphAdapter(adapter);
|
|
|
|
await render(FlowStrip, {
|
|
from: 'handleCallback',
|
|
to: 'decodeJwt',
|
|
symbols: null,
|
|
trailParam: null,
|
|
});
|
|
|
|
expect(calls).toContain('flow');
|
|
const text = host.textContent ?? '';
|
|
expect(text).toContain('handleCallback');
|
|
expect(text).toContain('parseToken');
|
|
});
|
|
|
|
it('ArchitectureMap draws modules and their dependency from a mock adapter', async () => {
|
|
const { adapter, calls } = mockAdapter();
|
|
setGraphAdapter(adapter);
|
|
|
|
await render(ArchitectureMap, { root: 'src', depth: 1, tests: false });
|
|
|
|
expect(calls).toContain('map');
|
|
const text = host.textContent ?? '';
|
|
expect(text).toContain('auth');
|
|
expect(text).toContain('http');
|
|
});
|
|
|
|
it('TrailBar and SearchPalette mount and read through the same adapter', async () => {
|
|
const { adapter } = mockAdapter();
|
|
setGraphAdapter(adapter);
|
|
|
|
trail.push({ id: SYMBOL.node.id, name: 'parseToken', kind: 'function', dir: 'start' });
|
|
await render(TrailBar, {});
|
|
expect(host.textContent ?? '').toContain('parseToken');
|
|
|
|
void unmount(mounted as Record<string, unknown>);
|
|
mounted = null;
|
|
host.innerHTML = '';
|
|
|
|
await render(SearchPalette, {});
|
|
expect(host.querySelector('input[role="combobox"]')).not.toBeNull();
|
|
});
|
|
|
|
it('offers no Save when the adapter cannot write, and says why in the list', async () => {
|
|
const { adapter } = mockAdapter();
|
|
setGraphAdapter(adapter);
|
|
|
|
trail.push({ id: SYMBOL.node.id, name: 'parseToken', kind: 'function', dir: 'start' });
|
|
await render(TrailBar, {});
|
|
// The one screen affordance that must never appear against a read-only
|
|
// host: an adapter with no `saveTrail` has no button, not a button that
|
|
// fails.
|
|
expect(host.textContent ?? '').not.toContain('Save trail');
|
|
|
|
void unmount(mounted as Record<string, unknown>);
|
|
mounted = null;
|
|
host.innerHTML = '';
|
|
|
|
await render(SavedTrails, { hideWhenEmpty: false });
|
|
const text = host.textContent ?? '';
|
|
expect(text).toContain('Saved trails');
|
|
expect(text).toContain('This host does not store trails.');
|
|
});
|
|
|
|
it('CodegraphUi installs the adapter before its children ask for data', async () => {
|
|
const { adapter, calls } = mockAdapter();
|
|
// NOT installed by hand — the provider is the only thing that installs it.
|
|
expect(getGraphAdapter()).not.toBe(adapter);
|
|
|
|
mounted = mount(CodegraphUi, { target: host, props: { adapter } }) as Record<string, unknown>;
|
|
flushSync();
|
|
expect(getGraphAdapter()).toBe(adapter);
|
|
expect(calls).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('@colbymchenry/codegraph-ui — the seams', () => {
|
|
it('a host navigation driver replaces every href the components build', () => {
|
|
const seen: string[] = [];
|
|
const driver: NavigationDriver = {
|
|
symbolHref: (id) => `/review/42/symbol/${encodeURIComponent(id)}`,
|
|
fileHref: (path) => `/review/42/file/${path}`,
|
|
mapHref: () => '/review/42/map',
|
|
flowHref: () => '/review/42/flow',
|
|
entryHref: () => '/review/42',
|
|
navigate: (href) => seen.push(href),
|
|
back: () => seen.push('back'),
|
|
};
|
|
setNavigationDriver(driver);
|
|
|
|
expect(symbolHref('function:x')).toBe('/review/42/symbol/function%3Ax');
|
|
expect(fileHref('src/a.ts')).toBe('/review/42/file/src/a.ts');
|
|
expect(mapHref()).toBe('/review/42/map');
|
|
expect(flowHref()).toBe('/review/42/flow');
|
|
|
|
setNavigationDriver(null);
|
|
// Back to the viewer's own address space, unchanged.
|
|
expect(symbolHref('function:x')).toBe(hashNavigation.symbolHref('function:x'));
|
|
expect(symbolHref('function:x')).toBe('#/s/function%3Ax');
|
|
});
|
|
|
|
it('gives the Symbol tab an address of its own when no symbol is chosen', async () => {
|
|
const { parseHash } = await import('../ui/src/lib/router.svelte');
|
|
|
|
// The regression this pins: the tab used to fall back to `#/`, and `#/` is
|
|
// the landing page — which renders the SCREENS tab on any project that has
|
|
// screens. Clicking Symbol landed you on somebody else's view.
|
|
expect(symbolHref(null)).toBe('#/s');
|
|
expect(parseHash('#/').route.view).toBe('home');
|
|
|
|
const empty = parseHash(symbolHref(null)).route;
|
|
expect(empty.view).toBe('symbol');
|
|
expect(empty).toMatchObject({ view: 'symbol', id: null });
|
|
|
|
// …and a chosen symbol still round-trips, id and all.
|
|
const chosen = parseHash(symbolHref('function:x')).route;
|
|
expect(chosen).toMatchObject({ view: 'symbol', id: 'function:x' });
|
|
});
|
|
|
|
it('sends every nav tab to its own view', async () => {
|
|
const { parseHash } = await import('../ui/src/lib/router.svelte');
|
|
const { entryHref, screensHref, stepsHref, deadHref } = await import(
|
|
'../ui/src/lib/navigation'
|
|
);
|
|
|
|
// One href per tab in the top bar, each parsed back. A tab whose link
|
|
// resolves to a different tab's view is the bug above, in general form.
|
|
const tabs: Array<[string, string]> = [
|
|
['screens', screensHref()],
|
|
['steps', stepsHref()],
|
|
['entry', entryHref()],
|
|
['map', mapHref()],
|
|
['symbol', symbolHref(null)],
|
|
['flow', flowHref()],
|
|
['dead', deadHref()],
|
|
];
|
|
for (const [view, href] of tabs) {
|
|
expect(parseHash(href).route.view, `${href} should open the ${view} view`).toBe(view);
|
|
}
|
|
});
|
|
|
|
it('the default adapter is the loopback JSON API and asks for `api/...`', async () => {
|
|
const asked: string[] = [];
|
|
const adapter = createHttpAdapter({
|
|
fetch: async (input) => {
|
|
asked.push(String(input));
|
|
return new Response(JSON.stringify(STATS), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
},
|
|
});
|
|
await adapter.stats();
|
|
await adapter.node('function:parse@a.ts:1');
|
|
await adapter.source({ file: 'src/a.ts', from: 1, to: 4 });
|
|
await adapter.nodes(['a', 'b']);
|
|
|
|
expect(asked[0]).toBe('api/stats');
|
|
// Ids are encoded per slash-separated segment, so ':' survives and '/' is
|
|
// still a path separator.
|
|
expect(asked[1]).toBe('api/node/function%3Aparse%40a.ts%3A1');
|
|
expect(asked[2]).toBe('api/source?file=src%2Fa.ts&from=1&to=4');
|
|
// Repeated `id` params, never a comma-joined list.
|
|
expect(asked[3]).toBe('api/nodes?id=a&id=b');
|
|
});
|
|
|
|
it('an adapter with no live channel never connects and never polls', () => {
|
|
const { adapter } = mockAdapter();
|
|
setGraphAdapter(adapter);
|
|
expect(adapter.events).toBeUndefined();
|
|
// `live.start()` is a no-op in a jsdom test that never called it; what is
|
|
// asserted here is the counters a host can still drive by hand.
|
|
const before = live.indexTick;
|
|
live.signal('index', { index: { lastIndexedAt: 1, files: 3 } });
|
|
expect(live.indexTick).toBe(before + 1);
|
|
});
|
|
});
|
|
|
|
describe('@colbymchenry/codegraph-ui — the published shape', () => {
|
|
const manifest = JSON.parse(
|
|
readFileSync(join(ROOT, 'ui', 'package.json'), 'utf8')
|
|
) as Record<string, any>;
|
|
|
|
it('is versioned with the engine', () => {
|
|
const engine = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')) as {
|
|
version: string;
|
|
};
|
|
expect(manifest.version).toBe(engine.version);
|
|
});
|
|
|
|
it('is named, scoped and not publishable by accident', () => {
|
|
expect(manifest.name).toBe('@colbymchenry/codegraph-ui');
|
|
// The package is PREPARED, not published (CG-61). `private` is the guard:
|
|
// npm refuses to publish it until the maintainer deliberately removes this.
|
|
expect(manifest.private).toBe(true);
|
|
});
|
|
|
|
it('exports the entry, the theme and nothing else', () => {
|
|
expect(Object.keys(manifest.exports).sort()).toEqual(['.', './package.json', './theme.css']);
|
|
expect(manifest.exports['.'].svelte).toBe('./dist/index.js');
|
|
expect(manifest.exports['.'].types).toBe('./dist/index.d.ts');
|
|
});
|
|
|
|
it('takes svelte as a peer, so a host never gets a second copy', () => {
|
|
expect(manifest.peerDependencies.svelte).toBeDefined();
|
|
expect(manifest.dependencies?.svelte).toBeUndefined();
|
|
// The canvas library is a real dependency: the Map and the Flow strip are
|
|
// unusable without it and a host must not have to know its version.
|
|
expect(manifest.dependencies['@xyflow/svelte']).toBeDefined();
|
|
});
|
|
});
|