From d08dfc41c3c727322be0b03d08199ab975ae90a9 Mon Sep 17 00:00:00 2001 From: Pinable conflict resolution Date: Mon, 28 Sep 2026 02:40:12 +0000 Subject: [PATCH 1/2] fix: retain Pinable database upgrades and UI regressions after PR 2060 merge Build on the concurrent upstream merge without rewriting it. Reconcile the legacy Pinable v10 schema with upstream synthesis migrations at v12, retain cursor indexes and both UI test suites, and keep batched trail queries scoped to the originating project. Add real SQLite upgrade/idempotency and adapter-switch regressions. --- __tests__/migrations-pinable-compat.test.ts | 129 ++++ __tests__/ui-package.test.ts | 671 ++++++++++++++++++++ src/db/migrations.ts | 63 +- ui/src/lib/api.ts | 6 +- 4 files changed, 846 insertions(+), 23 deletions(-) create mode 100644 __tests__/migrations-pinable-compat.test.ts diff --git a/__tests__/migrations-pinable-compat.test.ts b/__tests__/migrations-pinable-compat.test.ts new file mode 100644 index 0000000000..0dd54821a5 --- /dev/null +++ b/__tests__/migrations-pinable-compat.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { DatabaseConnection } from '../src/db'; +import { createDatabase, type SqliteDatabase } from '../src/db/sqlite-adapter'; +import { + CURRENT_SCHEMA_VERSION, getCurrentVersion, getMigrationHistory, + getPendingMigrations, runMigrations, +} from '../src/db/migrations'; + +const cursorIndexes = { + idx_edges_source_id: ['source', 'id'], + idx_edges_target_id: ['target', 'id'], + idx_nodes_file_id: ['file_path', 'id'], +}; +type Legacy = 'v9' | 'pinable-v10' | 'upstream-v10' | 'upstream-v11'; + +describe('Pinable/upstream schema-version collision (#2060)', () => { + let dir: string | undefined; + let connection: DatabaseConnection | undefined; + + afterEach(() => { + connection?.close(); + connection = undefined; + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + dir = undefined; + }); + + function fixture(origin: Legacy): { dbPath: string; version: number; ordinaryMetadata: string } { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-pinable-migration-')); + const dbPath = path.join(dir, 'index.db'); + const raw = createDatabase(dbPath).db; + const version = origin === 'v9' ? 9 : origin === 'upstream-v11' ? 11 : 10; + const legacySynthesis = origin === 'v9' || origin === 'pinable-v10'; + const ordinaryMetadata = origin === 'upstream-v10' ? '{"keep":true}' : 'malformed ordinary metadata'; + try { + // Keep the full real SQLite schema, then remove only the features that + // these historical versions did not contain. No database mocking. + raw.exec(fs.readFileSync(path.join(__dirname, '../src/db/schema.sql'), 'utf8')); + raw.exec('DELETE FROM schema_versions'); + raw.prepare('INSERT INTO schema_versions VALUES (?, 0, ?)').run(version, origin); + if (origin !== 'pinable-v10') { + for (const index of Object.keys(cursorIndexes)) raw.exec(`DROP INDEX ${index}`); + } + if (legacySynthesis) { + raw.exec(`DROP TABLE synthesis_inputs; + DROP INDEX idx_edges_synthesis_site; + DROP INDEX idx_nodes_kind; + CREATE INDEX idx_nodes_kind ON nodes(kind);`); + } else { + raw.prepare('INSERT INTO project_metadata VALUES (?, ?, ?)').run('synthesis_pending', '0', 7); + if (origin === 'upstream-v10') { + raw.exec(`DROP INDEX idx_edges_synthesis_site; + CREATE INDEX idx_edges_synthesis_site ON edges(json_extract(metadata, '$.registeredAt')) + WHERE json_extract(metadata, '$.synthesizedBy') IS NOT NULL;`); + } + } + raw.prepare('INSERT INTO project_metadata VALUES (?, ?, ?)').run('user_marker', 'unchanged', 7); + for (const [id, kind, file] of [['owner', 'struct', 'types.go'], ['method', 'method', 'methods.go']]) { + raw.prepare(`INSERT INTO files(path, content_hash, language, size, modified_at, indexed_at) + VALUES (?, 'hash', 'go', 100, 0, 0)`).run(file); + raw.prepare(`INSERT INTO nodes(id, kind, name, qualified_name, file_path, language, + start_line, end_line, start_column, end_column, updated_at) + VALUES (?, ?, ?, ?, ?, 'go', 1, 2, 0, 1, 0)`).run(id, kind, id, id, file); + } + const containment = legacySynthesis ? { keep: true } : { keep: true, synthesizedBy: 'go-method-contains' }; + raw.prepare("INSERT INTO edges(source, target, kind, metadata) VALUES ('owner', 'method', 'contains', ?)") + .run(JSON.stringify(containment)); + raw.prepare("INSERT INTO edges(source, target, kind, metadata) VALUES ('owner', 'method', 'calls', ?)") + .run(ordinaryMetadata); + } finally { raw.close(); } + return { dbPath, version, ordinaryMetadata }; + } + + function assertSchema(db: SqliteDatabase): void { + expect(getCurrentVersion(db)).toBe(CURRENT_SCHEMA_VERSION); + expect(CURRENT_SCHEMA_VERSION).toBe(12); + expect(getPendingMigrations(db)).toEqual([]); + expect(() => db.prepare('SELECT file_path FROM synthesis_inputs').all()).not.toThrow(); + for (const [index, columns] of Object.entries({ + ...cursorIndexes, idx_nodes_kind: ['kind', 'file_path', 'start_line', 'id'], + })) { + expect(db.prepare(`PRAGMA index_info(${index})`).all().map(row => row.name)).toEqual(columns); + } + const sql = db.prepare("SELECT sql FROM sqlite_master WHERE name = 'idx_edges_synthesis_site'").get().sql; + expect(sql).toContain('json_valid(metadata)'); + } + + it.each(['v9', 'pinable-v10', 'upstream-v10', 'upstream-v11']) + ('opens %s without losing graph data, synthesis state or pagination indexes', origin => { + const { dbPath, version, ordinaryMetadata } = fixture(origin); + connection = DatabaseConnection.open(dbPath); + const db = connection.getDb(); + assertSchema(db); + expect(db.prepare('SELECT id FROM nodes ORDER BY id').all()).toEqual([{ id: 'method' }, { id: 'owner' }]); + expect(db.prepare('SELECT COUNT(*) AS n FROM edges').get().n).toBe(2); + expect(db.prepare('SELECT COUNT(*) AS n FROM files').get().n).toBe(2); + expect(db.prepare("SELECT value FROM project_metadata WHERE key = 'user_marker'").get().value).toBe('unchanged'); + expect(db.prepare("SELECT metadata FROM edges WHERE kind = 'calls'").get().metadata).toBe(ordinaryMetadata); + expect(JSON.parse(db.prepare("SELECT metadata FROM edges WHERE kind = 'contains'").get().metadata)) + .toEqual({ keep: true, synthesizedBy: 'go-method-contains' }); + expect(db.prepare("SELECT value FROM project_metadata WHERE key = 'synthesis_pending'").get().value) + .toBe(origin === 'v9' || origin === 'pinable-v10' ? '1' : '0'); + expect(db.prepare('SELECT description FROM schema_versions WHERE version = ?').get(version).description).toBe(origin); + + // After synthesis completes, a no-op reopen and a replay of the new + // additive migration must not dirty the graph or request synthesis again. + db.exec("UPDATE project_metadata SET value = '0' WHERE key = 'synthesis_pending'"); + const rows = db.prepare('SELECT * FROM edges ORDER BY id').all(); + const history = getMigrationHistory(db); + runMigrations(db, getCurrentVersion(db)); + expect(getMigrationHistory(db)).toEqual(history); + connection.close(); + connection = DatabaseConnection.open(dbPath); + expect(getMigrationHistory(connection.getDb())).toEqual(history); + connection.getDb().exec('DELETE FROM schema_versions WHERE version = 12'); + runMigrations(connection.getDb(), 11); + assertSchema(connection.getDb()); + expect(connection.getDb().prepare('SELECT * FROM edges ORDER BY id').all()).toEqual(rows); + expect(connection.getDb().prepare("SELECT value FROM project_metadata WHERE key = 'synthesis_pending'").get().value).toBe('0'); + }); + + it('gives a fresh database the same synthesis and cursor schema', () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-pinable-fresh-')); + connection = DatabaseConnection.initialize(path.join(dir, 'index.db')); + assertSchema(connection.getDb()); + }); +}); diff --git a/__tests__/ui-package.test.ts b/__tests__/ui-package.test.ts index 901d03ceb0..1bb15d3877 100644 --- a/__tests__/ui-package.test.ts +++ b/__tests__/ui-package.test.ts @@ -17,6 +17,7 @@ * component reaches past the adapter for anything. */ +import type { WireNodeRefs } from '../ui/src/lib/wire'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { flushSync, mount, unmount } from 'svelte'; @@ -836,6 +837,652 @@ describe('@colbymchenry/codegraph-ui — the published shape', () => { }); }); +describe('工作区路由与请求隔离', () => { + it('解析项目路径并继续兼容旧链接', async () => { + const { parseHash } = await import('../ui/src/lib/router.svelte'); + expect(parseHash('#/p/alpha/s/function%3Ax?hl=3')).toMatchObject({ projectId: 'alpha', route: { view: 'symbol', id: 'function:x', line: 3 } }); + expect(parseHash('#/s/function%3Ax').projectId).toBeNull(); + expect(parseHash('#/workspace').route.view).toBe('workspace'); + }); + it('项目切换后的迟到统计不能覆盖新项目', async () => { + const { project } = await import('../ui/src/lib/project.svelte'); + const { setGraphAdapter } = await import('../ui/src/lib/adapter'); + let resolveOld!: (value: unknown) => void; + setGraphAdapter({ stats: () => new Promise(resolve => { resolveOld = resolve; }) } as never); + project.resetProject(); + const old = project.ensure(); + project.resetProject(); + setGraphAdapter({ stats: async () => ({ project: { name: '新项目' } }) } as never); + await project.ensure(); + resolveOld({ project: { name: '旧项目' } }); + await old; + expect(project.name).toBe('新项目'); + project.resetProject(); + setGraphAdapter(null); + }); + it('项目切换复用工作区摘要,不重复请求完整统计', async () => { + const { project } = await import('../ui/src/lib/project.svelte'); + const { setGraphAdapter } = await import('../ui/src/lib/adapter'); + const stats = { project: { name: '大型项目' }, graph: { nodes: 1_632_270, edges: 3_124_664, files: 65_791 } } as never; + const fetch = vi.fn(); + setGraphAdapter({ stats: fetch } as never); + project.resetProject(stats); + await project.ensure(); + expect(project.name).toBe('大型项目'); + expect(project.stats).toEqual(stats); + expect(fetch).not.toHaveBeenCalled(); + project.resetProject(); + setGraphAdapter(null); + }); +}); + +it('搜索返回恢复查询、项目范围和结果滚动', async () => { + const { default: WorkspaceSearch } = await import('../ui/src/components/WorkspaceSearch.svelte'); + const { workspace } = await import('../ui/src/lib/workspace.svelte'); + const projects = vi.spyOn(workspace, 'projects', 'get').mockReturnValue([{ id: 'beta', name: 'Beta', available: true }]); + const search = vi.spyOn(workspace, 'search').mockResolvedValue({ results: [{ projectId: 'beta', projectName: 'Beta', node: { id: 'function:x', name: 'target', kind: 'function', file: 'src/x.ts', line: 1, matchKind: 'exact' } as never }], incomplete: [], limited: false }); + mounted = mount(WorkspaceSearch, { target: host }); + const input = host.querySelector('input')!; + input.value = 'target path:src'; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new FocusEvent('focus')); + flushSync(); + await new Promise(resolve => setTimeout(resolve, 220)); flushSync(); + expect(search).toHaveBeenLastCalledWith('target path:src', '', expect.any(AbortSignal)); + const scope = host.querySelector('select')!; scope.value = 'beta'; scope.dispatchEvent(new Event('change', { bubbles: true })); flushSync(); + await new Promise(resolve => setTimeout(resolve, 220)); flushSync(); + const panel = host.querySelector('.results') as HTMLElement; + panel.scrollTop = 120; panel.dispatchEvent(new Event('scroll')); + const link = panel.querySelector('a')!; + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + await Promise.resolve(); + expect(document.activeElement).toBe(link); + expect(link.getAttribute('href')).toBe('#/p/beta/s/function%3Ax'); + link.dispatchEvent(new MouseEvent('click', { bubbles: true })); flushSync(); + expect(host.querySelector('.results')).toBeNull(); + window.dispatchEvent(new PopStateEvent('popstate')); flushSync(); + await Promise.resolve(); + expect(input.value).toBe('target path:src'); + expect((host.querySelector('select') as HTMLSelectElement).value).toBe('beta'); + expect((host.querySelector('.results') as HTMLElement).scrollTop).toBe(120); + expect(search).toHaveBeenLastCalledWith('target path:src', 'beta', expect.any(AbortSignal)); + search.mockRestore(); projects.mockRestore(); +}); + +it('项目切换隔离迟到入口、路径名称、保存路径和实时事件', async () => { + const { palette } = await import('../ui/src/lib/palette.svelte'); + const { trails } = await import('../ui/src/lib/trails.svelte'); + const { resolveTrailNames } = await import('../ui/src/lib/trail.svelte'); + let entryResolve!: (value: unknown) => void; + let trailResolve!: (value: unknown) => void; + let refsResolve!: (value: unknown) => void; + let handlers: import('../ui/src/lib/adapter').LiveHandlers | undefined; + let closed = 0; + setGraphAdapter({ + entryPoints: () => new Promise(resolve => { entryResolve = resolve; }), + trails: () => new Promise(resolve => { trailResolve = resolve; }), + nodes: () => new Promise(resolve => { refsResolve = resolve; }), + events: (next: import('../ui/src/lib/adapter').LiveHandlers) => { handlers = next; return () => { closed++; }; }, + } as never); + palette.resetProject(); trails.resetProject(); trail.resetProject(); live.stop(); + const entriesRequest = palette.ensureEntries(); + const trailsRequest = trails.reload(); + trail.push({ id: 'same-id' }); + const namesRequest = resolveTrailNames(); + live.start(); + palette.resetProject(); trails.resetProject(); trail.resetProject(); live.stop(); + trail.push({ id: 'same-id', name: '新名称' }); + entryResolve({ entries: ['旧入口'] }); trailResolve({ trails: [{ id: 'old' }] }); + refsResolve({ items: [{ id: 'same-id', name: '旧名称', kind: 'function' }], missing: [] }); + handlers?.index?.({ type: 'index', at: 1 }); + await Promise.all([entriesRequest, trailsRequest, namesRequest]); + expect(palette.entries).toBeNull(); expect(palette.entriesSettled).toBe(false); + expect(trails.list).toEqual([]); expect(trails.settled).toBe(false); + expect(trail.current?.name).toBe('新名称'); + expect(live.indexTick).toBe(0); expect(closed).toBe(1); + trail.resetProject(); setGraphAdapter(null); +}); + +it('一万条关系逐页浏览,保留调用位置并限制当前DOM为50条', async () => { + const { default: SymbolRelationships } = await import('../ui/src/components/SymbolRelationships.svelte'); + const requests: number[] = []; + setGraphAdapter({ neighbors: async (request: { cursor?: string }) => { + const start = Number(request.cursor ?? 0); requests.push(start); + return { items: Array.from({ length: 50 }, (_, i) => ({ edge: { id: `e${start + i}`, kind: 'calls', source: 'hub', target: 'target', file: 'src/hub.ts', line: start + i + 1 }, node: { id: 'target', kind: 'function', name: 'target', file: 'src/hub.ts', line: 5 } })), nextCursor: start + 50 < 10000 ? String(start + 50) : null, revision: 'r1' }; + } } as never); + mounted = mount(SymbolRelationships, { target: host, props: { id: 'hub', file: 'src/hub.ts' } }); flushSync(); + (host.querySelector('[data-open]') as HTMLButtonElement).click(); flushSync(); + await settle(); + expect(host.querySelectorAll('[data-relationship]')).toHaveLength(50); + expect(host.textContent).toContain('src/hub.ts:1'); + (host.querySelector('[data-next]') as HTMLButtonElement).click(); await settle(); + expect(requests).toEqual([0, 50]); + expect(host.querySelectorAll('[data-relationship]')).toHaveLength(50); + expect(host.textContent).toContain('src/hub.ts:51'); + expect(host.querySelector('[data-site]')?.getAttribute('href')).toBe('#/file/src/hub.ts?src=1&hl=51'); + for (let page = 2; page < 200; page++) { (host.querySelector('[data-next]') as HTMLButtonElement).click(); await settle(); } + expect(requests).toHaveLength(200); + expect(host.querySelectorAll('[data-relationship]')).toHaveLength(50); + expect(host.textContent).toContain('src/hub.ts:10000'); + expect((host.querySelector('[data-next]') as HTMLButtonElement).disabled).toBe(true); +}, 15000); + +async function settle() { for (let i = 0; i < 3; i++) { await new Promise(resolve => setTimeout(resolve, 0)); flushSync(); } } + +it('关系方向变更取消旧页并隔离迟到响应,失效游标可从第一页恢复', async () => { + const { default: SymbolRelationships } = await import('../ui/src/components/SymbolRelationships.svelte'); + const pending: Array<{ request: { direction: string; cursor?: string }; signal: AbortSignal; resolve: (value: unknown) => void; reject: (error: Error) => void }> = []; + setGraphAdapter({ neighbors: (request: never, signal: AbortSignal) => new Promise((resolve, reject) => pending.push({ request, signal, resolve, reject })) } as never); + mounted = mount(SymbolRelationships, { target: host, props: { id: 'hub', file: 'src/hub.ts' } }); flushSync(); + (host.querySelector('[data-open]') as HTMLButtonElement).click(); flushSync(); + const select = host.querySelector('select')!; select.value = 'out'; select.dispatchEvent(new Event('change', { bubbles: true })); flushSync(); + expect(pending[0].signal.aborted).toBe(true); + expect(pending[1].request.direction).toBe('out'); + const answer = (name: string, cursor: string | null) => ({ items: [{ edge: { kind: 'calls', line: 12 }, node: { id: name, name, file: 'src/target.ts', line: 3 } }], nextCursor: cursor, revision: 'r1' }); + pending[1].resolve(answer('当前结果', 'old-cursor')); await settle(); + pending[0].resolve(answer('迟到结果', null)); await settle(); + expect(host.textContent).toContain('当前结果'); expect(host.textContent).not.toContain('迟到结果'); + (host.querySelector('[data-next]') as HTMLButtonElement).click(); flushSync(); + expect(pending[2].request.cursor).toBe('old-cursor'); + pending[2].reject(new Error('索引已经更新,请重新加载列表。')); await settle(); + (host.querySelector('[data-retry]') as HTMLButtonElement).click(); flushSync(); + expect(pending[3].request.cursor).toBeUndefined(); + (host.querySelector('[data-open]') as HTMLButtonElement).click(); flushSync(); + expect(pending[3].signal.aborted).toBe(true); + pending[3].resolve(answer('关闭后结果', null)); await settle(); + expect(host.querySelectorAll('[data-relationship]')).toHaveLength(0); +}); + +it('旧适配器不显示全部关系入口', async () => { + const { default: SymbolRelationships } = await import('../ui/src/components/SymbolRelationships.svelte'); + setGraphAdapter({} as never); + mounted = mount(SymbolRelationships, { target: host, props: { id: 'hub', file: 'src/hub.ts' } }); flushSync(); + expect(host.querySelector('[data-open]')).toBeNull(); +}); + +it('工作区重新加载恢复离线项目且保留其他项目阅读上下文', async () => { + const { workspace } = await import('../ui/src/lib/workspace.svelte'); + const { default: WorkspaceOverview } = await import('../ui/src/components/WorkspaceOverview.svelte'); + let online = false; + const fetcher = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response(JSON.stringify({ name: '恢复测试', projects: [{ id: 'recover', name: '可恢复项目', available: online, error: online ? undefined : '索引离线' }, { id: 'other', name: '其他项目', available: true }] }))); + workspace.remember('other', '#/p/other/map?depth=3'); + await workspace.ensure(); + mounted = mount(WorkspaceOverview, { target: host }); flushSync(); + expect(host.textContent).toContain('索引离线'); + const retry = host.querySelector('[data-workspace-retry]') as HTMLButtonElement; + expect(retry).not.toBeNull(); + online = true; retry.click(); await settle(); + expect(workspace.projects.find(item => item.id === 'recover')?.available).toBe(true); + expect(workspace.href('other')).toBe('#/p/other/map?depth=3'); + expect(fetcher).toHaveBeenCalledTimes(2); + fetcher.mockRestore(); +}); + +it('当前离线项目可重试恢复,搜索与其他项目上下文不被清除', async () => { + const { workspace } = await import('../ui/src/lib/workspace.svelte'); + let online = false; + let reloadResolve: ((value: Response) => void) | undefined; + const overview = () => ({ name: '恢复工作台', projects: [{ id: 'recover', name: 'Recover', available: online, error: online ? undefined : '索引离线' }, { id: 'other', name: 'Other', available: true }] }); + const fetcher = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + if (url === 'api/workspace') { + if (online) return new Promise(resolve => { reloadResolve = resolve; }); + return new Response(JSON.stringify(overview())); + } + if (url.includes('workspace/search')) return new Response(JSON.stringify({ results: [], incomplete: [], limited: false })); + if (url.endsWith('/stats')) return new Response(JSON.stringify(STATS)); + return new Response(JSON.stringify({ error: 'fixture' }), { status: 404 }); + }); + await workspace.reload(); + workspace.remember('other', '#/p/other/map?depth=3'); + workspace.remember('recover', '#/p/recover/map?root=src&depth=3'); + workspace.remember('recover', '#/p/recover/steps?anchor=function%3Ahandler'); + hashNavigation.navigate('#/p/recover/'); + const { default: App } = await import('../ui/src/App.svelte'); + mounted = mount(App, { target: host }); await settle(); + expect(host.textContent).toContain('索引离线'); + const input = host.querySelector('input[aria-label="全局搜索"]')!; + input.value = 'target path:src'; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new FocusEvent('focus')); flushSync(); + await new Promise(resolve => setTimeout(resolve, 210)); flushSync(); + online = true; + const retry = host.querySelector('[data-workspace-retry]')!; + retry.click(); flushSync(); + expect(retry.disabled).toBe(true); + const inFlight = workspace.reload(); + reloadResolve!(new Response(JSON.stringify(overview()))); + await inFlight; await settle(); + expect(workspace.activeId).toBe('recover'); + expect(host.querySelector('aside a[title="架构依赖"]')?.getAttribute('href')).toBe('#/p/recover/map?root=src&depth=3'); + expect(host.querySelector('aside a[title="执行链路"]')?.getAttribute('href')).toBe('#/p/recover/steps?anchor=function%3Ahandler'); + expect(host.querySelector('footer')?.textContent).toContain('索引总量'); + expect(host.querySelector('footer')?.textContent).not.toContain('当前显示'); + expect(host.textContent).not.toContain('索引离线'); + expect(input.value).toBe('target path:src'); + expect(host.querySelector('.results')).not.toBeNull(); + expect(workspace.href('other')).toBe('#/p/other/map?depth=3'); + expect(fetcher.mock.calls.filter(([url]) => String(url) === 'api/workspace')).toHaveLength(2); + fetcher.mockRestore(); +}); + +it.each(['map', 'screens', 'steps', 'flow'] as const)('%s 忽略取消的迟到请求不能覆盖最新索引响应', async (kind) => { + const pending: Array<(value: unknown) => void> = []; + const { adapter } = mockAdapter(); + setGraphAdapter({ ...adapter, [kind]: () => new Promise(resolve => pending.push(resolve)) } as GraphAdapter); + const components = { + map: ArchitectureMap, + screens: (await import('../ui/src/views/ScreensView.svelte')).default, + steps: (await import('../ui/src/views/StepsView.svelte')).default, + flow: FlowStrip, + }; + const props = kind === 'map' ? { root: null, depth: 2, tests: false } + : kind === 'steps' ? { anchor: 'anchor', symbol: null, depth: 4, through: false, reading: 'tree' } + : kind === 'flow' ? { from: 'from', to: 'to', symbols: null, trailParam: null } : {}; + await render(components[kind], props); + live.signal('index'); flushSync(); + await new Promise(resolve => setTimeout(resolve, 0)); flushSync(); + expect(pending).toHaveLength(2); + const response = (latest: boolean) => { + const marker = latest ? 'CURRENT_RESPONSE' : 'STALE_RESPONSE'; + const budget = { exceeded: true, nodes: latest ? 410002 : 410001, edges: 0, maxNodes: 400, maxEdges: 2000 }; + if (kind === 'map') return { ...MAP, roots: [{ root: 'src', label: marker, files: 2 }] }; + if (kind === 'flow') return { ...FLOW, flows: FLOW.flows.map(flow => ({ ...flow, label: marker })) }; + if (kind === 'steps') return { anchor: { ...SYMBOL.node, name: marker }, steps: [], links: [], program: null, defaultView: 'tree', budget }; + return { routed: true, screens: [], origins: [], links: [], budget }; + }; + pending[1]!(response(true)); + await new Promise(resolve => setTimeout(resolve, 0)); flushSync(); + const marker = kind === 'screens' ? '410002' : 'CURRENT_RESPONSE'; + expect(host.textContent).toContain(marker); + pending[0]!(response(false)); + await new Promise(resolve => setTimeout(resolve, 0)); flushSync(); + expect(host.textContent).toContain(marker); + expect(host.textContent).not.toContain(kind === 'screens' ? '410001' : 'STALE_RESPONSE'); +}); + +it('Steps入口列表忽略取消后迟到的routes响应', async () => { + const pending: Array<(value: unknown) => void> = []; + const { adapter } = mockAdapter(); + setGraphAdapter({ ...adapter, steps: async () => ({}), screens: async () => ({ routed: false, screens: [] }), routes: () => new Promise(resolve => pending.push(resolve)) } as unknown as GraphAdapter); + const Steps = (await import('../ui/src/views/StepsView.svelte')).default; + await render(Steps, { anchor: null, symbol: null, depth: 4, through: false, reading: 'tree' }); + live.signal('index'); flushSync(); + await new Promise(resolve => setTimeout(resolve, 0)); flushSync(); + const response = (name: string) => ({ routed: true, entries: [{ routeId: name, routeFile: 'src/routes.ts', routeLine: 1, url: name, handler: name }] }); + pending[1]!(response('CURRENT_ROUTE')); + await new Promise(resolve => setTimeout(resolve, 0)); flushSync(); + pending[0]!(response('STALE_ROUTE')); + await new Promise(resolve => setTimeout(resolve, 0)); flushSync(); + expect(host.textContent).toContain('CURRENT_ROUTE'); + expect(host.textContent).not.toContain('STALE_ROUTE'); +}); + +it('目录游标失效后可以重新加载第一页并丢弃旧游标', async () => { + const DirectoryBrowser = (await import('../ui/src/components/graph/DirectoryBrowser.svelte')).default; + const requests: Array<{ kind: string; cursor?: string }> = []; + let refreshed = false; + const { adapter } = mockAdapter(); + setGraphAdapter({ ...adapter, browse: async request => { + requests.push(request); + if (request.cursor) throw new Error('stale cursor'); + return { items: request.kind === 'files' ? [{ kind: 'file', path: refreshed ? 'src/new.ts' : 'src/old.ts' }] : [], nextCursor: request.kind === 'files' && !refreshed ? 'expired' : null, revision: refreshed ? 'v2' : 'v1' }; + } }); + await render(DirectoryBrowser, { root: 'src', onOpen: () => {} }); + const more = [...host.querySelectorAll('button')].find(button => /加载更多文件|Load more files/.test(button.textContent ?? ''))!; + more.click(); flushSync(); + await new Promise(resolve => setTimeout(resolve, 0)); flushSync(); + expect(host.textContent).toContain('stale cursor'); + refreshed = true; + const reload = [...host.querySelectorAll('button')].find(button => /重新加载第一页|Reload first page/.test(button.textContent ?? ''))!; + reload.click(); flushSync(); + await new Promise(resolve => setTimeout(resolve, 0)); flushSync(); + expect(host.textContent).toContain('src/new.ts'); + expect(host.textContent).not.toContain('src/old.ts'); + expect(host.textContent).not.toContain('stale cursor'); + expect(requests.filter(request => request.cursor)).toHaveLength(1); +}); + +it('项目阅读上下文分别恢复架构范围和执行入口', async () => { + const { workspace } = await import('../ui/src/lib/workspace.svelte'); + workspace.remember('context-a', '#/p/context-a/map?root=src&depth=3'); + workspace.remember('context-a', '#/p/context-a/steps?anchor=function%3Ahandler&depth=4'); + workspace.remember('context-b', '#/p/context-b/map?root=lib&depth=2'); + expect(workspace.href('context-a')).toBe('#/p/context-a/steps?anchor=function%3Ahandler&depth=4'); + expect(workspace.href('context-a', 'map')).toBe('#/p/context-a/map?root=src&depth=3'); + expect(workspace.href('context-a', 'steps')).toBe('#/p/context-a/steps?anchor=function%3Ahandler&depth=4'); + expect(workspace.href('context-a', 'execution')).toBe('#/p/context-a/steps?anchor=function%3Ahandler&depth=4'); + expect(workspace.href('context-b', 'map')).toBe('#/p/context-b/map?root=lib&depth=2'); + expect(workspace.href('context-a', 'flow')).toBe('#/p/context-a/flow'); +}); + +it('画布状态销毁与项目重置不能清除新画布状态', async () => { + const { graphStatus } = await import('../ui/src/lib/graph-status.svelte'); + const old = graphStatus.set({ nodes: 10, edges: 20 }); + const next = graphStatus.set({ nodes: 30, edges: 40, scope: 'src' }); + old(); expect(graphStatus.current?.nodes).toBe(30); + graphStatus.resetProject(); expect(graphStatus.current).toBeNull(); + const current = graphStatus.set({ nodes: 1, edges: 2 }); + next(); expect(graphStatus.current?.nodes).toBe(1); + current(); expect(graphStatus.current).toBeNull(); +}); + +it('Flow已返回路径而Worker尚未完成时显示布局中,不误称没有路径', async () => { + class PausedWorker { + static instances: PausedWorker[] = []; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: ((event: ErrorEvent) => void) | null = null; + constructor() { PausedWorker.instances.push(this); } + postMessage() {} + terminate() {} + } + vi.stubGlobal('Worker', PausedWorker); + try { + setGraphAdapter(mockAdapter().adapter); + await render(FlowStrip, { from: 'from', to: 'to', symbols: null, trailParam: null }); + expect(host.textContent).toMatch(/正在布局已找到的路径|Laying out the returned paths/); + expect(host.textContent).not.toContain('No path between them'); + const { buildFlowLayout } = await import('../ui/src/lib/flow-model'); + PausedWorker.instances[0]!.onmessage!({ data: { result: buildFlowLayout(FLOW.flows, FLOW.flows[0]!.id) } } as MessageEvent); + flushSync(); + expect(host.textContent).toContain('parseToken'); + expect(host.textContent).not.toMatch(/正在布局已找到的路径|Laying out the returned paths/); + } finally { vi.unstubAllGlobals(); } +}); + +it('Steps入口忽略取消后迟到的screens响应', async () => { + const pending: Array<(value: unknown) => void> = []; + setGraphAdapter({ ...mockAdapter().adapter, steps: async () => ({}), screens: () => new Promise(resolve => pending.push(resolve)) } as unknown as GraphAdapter); + const Steps = (await import('../ui/src/views/StepsView.svelte')).default; + await render(Steps, { anchor: null, symbol: null, depth: 4, through: false, reading: 'tree' }); + live.signal('index'); flushSync(); + await new Promise(resolve => setTimeout(resolve, 0)); flushSync(); + pending[1]!({ routed: true, screens: [{ id: 'current', path: 'CURRENT_SCREEN', component: null, file: 'new.ts', incoming: 0, outgoing: 0 }] }); + await new Promise(resolve => setTimeout(resolve, 0)); flushSync(); + pending[0]!({ routed: false, screens: [] }); + await new Promise(resolve => setTimeout(resolve, 0)); flushSync(); + expect(host.textContent).toContain('CURRENT_SCREEN'); +}); + +it('Map通过G6场景保留模块标签和键盘选择入口', async () => { + setGraphAdapter(mockAdapter().adapter); + await render(ArchitectureMap, { root: null, depth: 2, tests: false }); + await vi.waitFor(() => expect(host.querySelector('[data-graph-engine="g6"]')).not.toBeNull()); + expect(host.querySelectorAll('.accessible button').length).toBeGreaterThan(0); + expect(g6Harness.scene.nodes.every((n: any) => n.width > 0 && n.height > 0)).toBe(true); + }); + +it('同项目乱序刷新只采用最后一次统计、入口和路径列表', async () => { + const { project } = await import('../ui/src/lib/project.svelte'); + const { palette } = await import('../ui/src/lib/palette.svelte'); + const { trails } = await import('../ui/src/lib/trails.svelte'); + const pending: Record void>> = { stats: [], entries: [], trails: [] }; + const queue = (key: string) => () => new Promise(resolve => pending[key]!.push(resolve)); + setGraphAdapter({ stats: queue('stats'), entryPoints: queue('entries'), trails: queue('trails') } as never); + project.resetProject(); palette.resetProject(); trails.resetProject(); + const old = [project.ensure(), palette.ensureEntries(), trails.ensure()]; + const latest = [project.reload(), palette.reloadEntries(), trails.reload()]; + pending.stats[1]!({ project: { name: '最新项目统计' } }); + pending.entries[1]!({ marker: '最新入口' }); pending.trails[1]!({ trails: [{ id: 'latest' }] }); + await Promise.all(latest); + pending.stats[0]!({ project: { name: '旧项目统计' } }); + pending.entries[0]!({ marker: '旧入口' }); pending.trails[0]!({ trails: [{ id: 'old' }] }); + await Promise.all(old); + expect(project.name).toBe('最新项目统计'); + expect(palette.entries).toEqual({ marker: '最新入口' }); + expect(trails.list.map(item => item.id)).toEqual(['latest']); + project.resetProject(); palette.resetProject(); trails.resetProject(); setGraphAdapter(null); +}); + +it('切换搜索范围失败时不保留上一范围结果,清空输入清除旧提示', async () => { + const { workspace } = await import('../ui/src/lib/workspace.svelte'); + const { default: WorkspaceSearch } = await import('../ui/src/components/WorkspaceSearch.svelte'); + const projects = vi.spyOn(workspace, 'projects', 'get').mockReturnValue([{ id: 'a', name: 'A', available: true }, { id: 'b', name: 'B', available: true }]); + const search = vi.spyOn(workspace, 'search').mockResolvedValueOnce({ results: [{ projectId: 'a', projectName: 'A', node: { id: 'old', name: '旧范围结果', file: 'a.ts', kind: 'function' } as never }], limited: true, incomplete: [{ projectId: 'b', reason: '旧提示' }] }).mockRejectedValue(new Error('B搜索失败')); + try { + mounted = mount(WorkspaceSearch, { target: host }); flushSync(); + const input = host.querySelector('input')!; input.value = 'target'; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new FocusEvent('focus')); flushSync(); + await new Promise(resolve => setTimeout(resolve, 210)); flushSync(); + expect(host.textContent).toContain('旧范围结果'); + const scope = host.querySelector('select')!; scope.value = 'b'; scope.dispatchEvent(new Event('change', { bubbles: true })); flushSync(); + expect(host.textContent).not.toContain('旧范围结果'); + await new Promise(resolve => setTimeout(resolve, 210)); flushSync(); + expect(host.textContent).toContain('B搜索失败'); + expect(host.querySelector('.results a')).toBeNull(); + expect(host.textContent).not.toContain('结果已截断'); + input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true })); flushSync(); + expect(host.textContent).not.toContain('B搜索失败'); + expect(host.textContent).not.toContain('旧提示'); + } finally { search.mockRestore(); projects.mockRestore(); } +}); + +it('旧刷新失败不会写入错误或提前结束最新读取,刷新不使保存失效', async () => { + const { project } = await import('../ui/src/lib/project.svelte'); + const { palette } = await import('../ui/src/lib/palette.svelte'); + const { trails } = await import('../ui/src/lib/trails.svelte'); + type Deferred = { resolve: (value: unknown) => void; reject: (error: Error) => void }; + const pending: Record = { stats: [], entries: [], trails: [], save: [] }; + const queue = (key: string) => () => new Promise((resolve, reject) => pending[key]!.push({ resolve, reject })); + setGraphAdapter({ stats: queue('stats'), entryPoints: queue('entries'), trails: queue('trails'), saveTrail: queue('save') } as never); + project.resetProject(); palette.resetProject(); trails.resetProject(); + try { + const old = [project.ensure(), palette.ensureEntries(), trails.ensure()]; + const save = trails.save('保留保存', '', []); + const latest = [project.reload(), palette.reloadEntries(), trails.reload()]; + for (const key of ['stats', 'entries', 'trails']) pending[key]![0]!.reject(new Error('旧读取失败')); + await Promise.all(old); + expect(project.error).toBeNull(); expect(palette.entriesFailure).toBeNull(); expect(trails.failure).toBeNull(); + expect(palette.entriesSettled).toBe(false); expect(trails.settled).toBe(false); + pending.stats[1]!.resolve({ project: { name: '最新统计' } }); + pending.entries[1]!.resolve({ marker: '最新入口' }); pending.trails[1]!.resolve({ trails: [] }); + await Promise.all(latest); + pending.save[0]!.resolve({ trails: [{ id: 'saved' }], saved: 'saved' }); + expect(await save).toBe('saved'); + expect(trails.list.map(item => item.id)).toEqual(['saved']); + expect(trails.busy).toBe(false); + } finally { project.resetProject(); palette.resetProject(); trails.resetProject(); setGraphAdapter(null); } +}); + +it('G6场景合并双向展示边但保留真实有向关系', async () => { + setGraphAdapter(mockAdapter().adapter); + await render(ArchitectureMap, { root: 'src', depth: 1, tests: false }); + await vi.waitFor(() => expect(g6Harness.scene?.kind).toBe('map')); + expect(g6Harness.scene.relations.length).toBeGreaterThan(0); + expect(g6Harness.scene.edges.every((e: any) => e.originalIds.length && e.width <= 2)).toBe(true); + }); + +it('G6节点移动保存位置,流向播放和恢复布局保持可用', async () => { + const { saveGraphHistory, readGraphHistory } = await import('../ui/src/lib/graph-history'); + saveGraphHistory(location.href, { positions: {}, selected: null, flowPlaying: false }); + setGraphAdapter(mockAdapter().adapter); + await render(ArchitectureMap, { root: 'src', depth: 1, tests: false }); + await vi.waitFor(() => expect(g6Harness.scene?.nodes.length).toBeGreaterThan(0)); + const n = g6Harness.scene.nodes[0]; g6Harness.events.move(n.id, n.x + 10, n.y); flushSync(); + expect(readGraphHistory(location.href).positions?.[n.id]?.x).toBe(n.x + 10); + const play = [...host.querySelectorAll('button')].find(b => /播放流向|Play flow/.test(b.textContent ?? ''))!; + play.click(); flushSync(); await vi.waitFor(() => expect(g6Harness.scene.edges.some((e: any) => e.flowing)).toBe(true)); + play.click(); flushSync(); expect(readGraphHistory(location.href).flowPlaying).toBe(false); + const reset = [...host.querySelectorAll('button')].find(b => /恢复自动布局|Reset layout/.test(b.textContent ?? ''))!; + expect(reset.disabled).toBe(false); reset.click(); flushSync(); expect(readGraphHistory(location.href).positions).toEqual({}); + }); + +it('旧直角折线视图回退为直线,选择器只保留曲线和直线', async () => { + const { saveGraphHistory, readGraphHistory } = await import('../ui/src/lib/graph-history'); + saveGraphHistory(location.href, { edgeStyle: 'orthogonal' as unknown as import('../ui/src/lib/map-edge-path').MapEdgeStyle }); + setGraphAdapter(mockAdapter().adapter); + await render(ArchitectureMap, { root: 'src', depth: 1, tests: false }); + const style = host.querySelector('option[value="straight"]')!.parentElement as HTMLSelectElement; + expect([...style.options].map(option => option.value)).toEqual(['curve', 'straight']); + expect(style.value).toBe('straight'); + expect(readGraphHistory(location.href).edgeStyle).toBe('straight'); + expect(g6Harness.scene.edges.every((e: any) => !e.path || /^M[^C]+ L/.test(e.path))).toBe(true); +}); + +it('连线切换保留位置,紧凑排列将聚焦节点移近并保存当前线型', async () => { + const { saveGraphHistory, readGraphHistory } = await import('../ui/src/lib/graph-history'); + saveGraphHistory(location.href, { positions: { 'src/http': { x: 50000, y: 50000 } }, selected: 'src/auth', focusOnly: true, focusDirection: 'both', edgeStyle: 'curve', viewport: { x: 0, y: 0, zoom: 1 } }); + setGraphAdapter(mockAdapter().adapter); + await render(ArchitectureMap, { root: 'src', depth: 1, tests: false }); + const style = host.querySelector('option[value="straight"]')!.parentElement as HTMLSelectElement; + style.value = 'straight'; style.dispatchEvent(new Event('change', { bubbles: true })); flushSync(); + expect(readGraphHistory(location.href).edgeStyle).toBe('straight'); + expect(readGraphHistory(location.href).positions?.['src/http']).toEqual({ x: 50000, y: 50000 }); + const compact = [...host.querySelectorAll('button')].find(button => /紧凑排列|Compact layout/.test(button.textContent ?? ''))!; + expect(compact.disabled).toBe(false); + compact.click(); flushSync(); + await vi.waitFor(() => expect(readGraphHistory(location.href).positions?.['src/http']?.x).toBeLessThan(1000)); + expect(Object.keys(readGraphHistory(location.href).positions ?? {}).sort()).toEqual(['src/auth', 'src/http']); + expect(g6Harness.scene.edges.every((e: any) => !e.path || /^M[^C]+ L/.test(e.path))).toBe(true); + style.value = 'curve'; style.dispatchEvent(new Event('change', { bubbles: true })); flushSync(); + expect(g6Harness.scene.edges.every((e: any) => !e.path)).toBe(true); + expect(readGraphHistory(location.href).edgeStyle).toBe('curve'); +}); + +it('紧凑排列期间切换选中对象,迟到Worker结果不能覆盖位置', async () => { + const { saveGraphHistory, readGraphHistory } = await import('../ui/src/lib/graph-history'); + const { calculateLayout } = await import('../ui/src/lib/graph-layout-runner'); + const pending: ControlledWorker[] = []; + class ControlledWorker { + onmessage: ((event: { data: { result: unknown } }) => void) | null = null; + onerror = null; stopped = false; + postMessage(job: { kind: string; payload: unknown; options: Record }) { + if (job.kind === 'map-compact') pending.push(this); + else queueMicrotask(() => this.onmessage?.({ data: { result: calculateLayout(job.kind, job.payload, job.options) } })); + } + terminate() { this.stopped = true; } + } + const original = globalThis.Worker; + globalThis.Worker = ControlledWorker as unknown as typeof Worker; + try { + saveGraphHistory(location.href, { positions: {}, selected: 'src/auth', focusOnly: true, edgeStyle: 'curve', viewport: { x: 0, y: 0, zoom: 1 } }); + setGraphAdapter(mockAdapter().adapter); + await render(ArchitectureMap, { root: 'src', depth: 1, tests: false }); + [...host.querySelectorAll('button')].find(button => /紧凑排列|Compact layout/.test(button.textContent ?? ''))!.click(); flushSync(); + expect(pending).toHaveLength(1); + [...host.querySelectorAll('.accessible button')].find(button => button.textContent?.includes('http'))!.click(); flushSync(); + expect(pending[0]!.stopped).toBe(true); + pending[0]!.onmessage?.({ data: { result: { 'src/auth': { x: 9999, y: 9999 } } } }); flushSync(); + expect(readGraphHistory(location.href).positions).toEqual({}); + expect(readGraphHistory(location.href).selected).toBe('src/http'); + } finally { globalThis.Worker = original; } +}); + +it('关系布局切换保留真实边,选择不重排,手动位置按布局隔离并恢复历史', async () => { + const { default: GraphCanvas } = await import('../ui/src/components/graph/GraphCanvas.svelte'); + const { saveGraphHistory, readGraphHistory } = await import('../ui/src/lib/graph-history'); + const scene: import('../ui/src/lib/graph-scene').GraphScene = {kind:'map',groups:[], + nodes:['a','b','c','d'].map((id,i)=>({id,label:id,sub:'',kind:'module',x:i*240,y:0,width:180,height:48,draggable:true})), + relations:[{id:'ab',source:'a',target:'b'}],edges:[{id:'ab',source:'a',target:'b',label:'1',width:1,originalIds:['ab'],path:'M180,24 L240,24',straight:true}]}; + const originalPositions=scene.nodes.map(n=>[n.x,n.y]); + // Vitest omits component styles; load the compiled CSS to exercise pointer hit testing rules. + const { compile } = await import('svelte/compiler'); + const canvasCss = compile(readFileSync(join(process.cwd(), 'ui/src/components/graph/GraphCanvas.svelte'), 'utf8'), { css: 'external' }).css!.code; + const stylesheet = document.createElement('style'); + // Match the mounted component's scope (Vite supplies its own filename to the compiler). + const compiledScope = canvasCss.match(/svelte-[\w-]+/)![0]; + const selectMode=(mode:string)=>{const select=host.querySelector('select[aria-label="图布局"]')!;select.value=mode;select.dispatchEvent(new Event('change',{bubbles:true}));flushSync();}; + const ready=()=>vi.waitFor(()=>expect(host.querySelector('[data-graph-engine]')?.getAttribute('aria-busy')).toBe('false')); + saveGraphHistory(location.href,{relationshipLayout:'default',layoutPositions:{},collapsedGroups:[],selected:null,analysisFocus:false}); + try { + await render(GraphCanvas,{scene}); + const toolbar = host.querySelector('.toolbar')!; + const mountedScope = [...toolbar.classList].find(name => name.startsWith('svelte-'))!; + stylesheet.textContent = canvasCss.replaceAll(compiledScope, mountedScope); + document.head.append(stylesheet); + const layoutTrigger = host.querySelector('button[aria-label="图布局"]')!; + expect(getComputedStyle(toolbar).pointerEvents).toBe('none'); + expect(getComputedStyle(layoutTrigger.parentElement!).pointerEvents).toBe('auto'); + layoutTrigger.click(); + const layoutMenu = layoutTrigger.parentElement!.querySelector('[role="listbox"]')!; + expect(layoutMenu.hidden).toBe(false); + [...layoutMenu.querySelectorAll('[role="option"]')].find(option => option.textContent === '环形')!.click(); + flushSync(); await ready(); + expect(host.querySelector('[data-graph-engine]')?.getAttribute('data-layout')).toBe('circular'); + expect(layoutMenu.hidden).toBe(true); + expect(g6Harness.scene.nodes.map((n:any)=>[n.x,n.y])).not.toEqual(originalPositions); + expect(g6Harness.scene.relations).toEqual(scene.relations); + expect(g6Harness.scene.edges[0]).toMatchObject({path:undefined,straight:true,originalIds:['ab']}); + const arranged=g6Harness.scene.nodes.map((n:any)=>[n.id,n.x,n.y]); + g6Harness.events.select('a');flushSync(); + expect(g6Harness.scene.nodes.map((n:any)=>[n.id,n.x,n.y])).toEqual(arranged); + g6Harness.events.move('a',800,900);flushSync(); + expect(readGraphHistory(location.href).layoutPositions?.circular?.a).toEqual({x:800,y:900}); + selectMode('concentric');await ready(); + expect(g6Harness.scene.nodes.find((n:any)=>n.id==='a').x).not.toBe(800); + selectMode('circular');await ready(); + expect(g6Harness.scene.nodes.find((n:any)=>n.id==='a')).toMatchObject({x:800,y:900}); + await unmount(mounted!);mounted=null;await render(GraphCanvas,{scene});await ready(); + expect(host.querySelector('select[aria-label="图布局"]')!.value).toBe('circular'); + expect(g6Harness.scene.nodes.find((n:any)=>n.id==='a')).toMatchObject({x:800,y:900}); + g6Harness.events.viewport({x:15,y:25,zoom:1});flushSync(); + expect(host.querySelector('.graph-canvas')!.style.backgroundPosition).toBe('15px 25px'); + expect(host.querySelector('.graph-canvas')!.style.backgroundSize).toBe('30px 30px'); + [...host.querySelectorAll('button')].find(b=>b.textContent==='恢复布局')!.click();flushSync();await ready(); + expect(g6Harness.scene.nodes.map((n:any)=>[n.x,n.y])).toEqual(originalPositions); + expect(readGraphHistory(location.href).layoutPositions).toEqual({}); + } finally { stylesheet.remove(); saveGraphHistory(location.href,{relationshipLayout:'default',layoutPositions:{},selected:null}); } +}); + +it('架构图最近查看最多十个,去重置顶,重开保留,点击展开并定位,跨范围隔离',async()=>{ + const { default: GraphCanvas } = await import('../ui/src/components/graph/GraphCanvas.svelte'); + const { saveGraphHistory, readGraphHistory } = await import('../ui/src/lib/graph-history'); + const key=location.href; + const scene:import('../ui/src/lib/graph-scene').GraphScene={kind:'map',groups:[],relations:[],edges:[], + nodes:Array.from({length:12},(_,i)=>({id:`src/n${i}`,label:`节点 ${i}`,sub:'',kind:'module',x:i*220,y:0,width:180,height:48}))}; + const recent=()=>[...host.querySelectorAll('[data-recent-node]')]; + saveGraphHistory(key,{relationshipLayout:'default',recentNodes:[],selected:null,collapsedGroups:[],analysisFocus:false}); + try { + await render(GraphCanvas,{scene});expect(host.textContent).toContain('暂无记录'); + for(const node of scene.nodes){g6Harness.events.select(node.id);flushSync();} + expect(recent().map(b=>b.dataset.recentNode)).toEqual(Array.from({length:10},(_,i)=>`src/n${11-i}`)); + g6Harness.events.select('src/n5');flushSync(); + expect(recent()).toHaveLength(10);expect(recent()[0]!.dataset.recentNode).toBe('src/n5'); + g6Harness.events.select(null);flushSync();expect(recent()).toHaveLength(10); + await unmount(mounted!);mounted=null; + saveGraphHistory(key,{collapsedGroups:['directory:src']}); + await render(GraphCanvas,{scene}); + expect(recent()[0]!.dataset.recentNode).toBe('src/n5'); + recent().find(b=>b.dataset.recentNode==='src/n2')!.click();flushSync(); + await vi.waitFor(()=>expect(g6Harness.focused).toEqual(['src/n2'])); + expect(readGraphHistory(key).selected).toBe('src/n2'); + expect(readGraphHistory(key).collapsedGroups).toEqual([]); + expect(recent()[0]!.dataset.recentNode).toBe('src/n2'); + await unmount(mounted!);mounted=null; + await render(GraphCanvas,{scene:{...scene,nodes:scene.nodes.filter(n=>n.id!=='src/n2')}}); + expect(recent()[0]!.disabled).toBe(true); + await unmount(mounted!);mounted=null; + window.history.replaceState(null,'','#/p/recent-other/map'); + await render(GraphCanvas,{scene});expect(recent()).toHaveLength(0); + } finally { + if(mounted){await unmount(mounted);mounted=null;} + window.history.replaceState(null,'',key);saveGraphHistory(key,{recentNodes:[],selected:null,collapsedGroups:[]}); + } +}); + +it('切换关系布局取消旧Worker,折叠与选择不重排,卸载取消尚未完成的计算',async()=>{ + const { default: GraphCanvas } = await import('../ui/src/components/graph/GraphCanvas.svelte'); + const { saveGraphHistory } = await import('../ui/src/lib/graph-history'); + const pending:ControlledWorker[]=[]; + class ControlledWorker { + onmessage:((event:{data:{result:unknown}})=>void)|null=null;onerror=null;stopped=false; + postMessage(){pending.push(this);}terminate(){this.stopped=true;} + } + const original=globalThis.Worker;globalThis.Worker=ControlledWorker as unknown as typeof Worker; + const scene:import('../ui/src/lib/graph-scene').GraphScene={kind:'map',groups:[],relations:[],edges:[], + nodes:['src/a','src/b'].map(id=>({id,label:id,sub:'',kind:'module',x:0,y:0,width:180,height:48}))}; + const selectMode=(mode:string)=>{const select=host.querySelector('select[aria-label="图布局"]')!;select.value=mode;select.dispatchEvent(new Event('change',{bubbles:true}));flushSync();}; + saveGraphHistory(location.href,{relationshipLayout:'default',layoutPositions:{},collapsedGroups:[],selected:null,analysisFocus:false}); + try { + await render(GraphCanvas,{scene}); + selectMode('force');expect(pending).toHaveLength(1); + selectMode('circular');expect(pending[0]!.stopped).toBe(true);expect(pending).toHaveLength(2); + pending[0]!.onmessage?.({data:{result:{'src/a':{x:9999,y:9999},'src/b':{x:9999,y:9999}}}});flushSync(); + expect(host.querySelector('[data-graph-engine]')?.getAttribute('aria-busy')).toBe('true'); + pending[1]!.onmessage?.({data:{result:{'src/a':{x:100,y:100},'src/b':{x:400,y:100}}}});flushSync(); + expect(g6Harness.scene.nodes[0].x).toBe(100); + g6Harness.events.select('src/a');flushSync();expect(pending).toHaveLength(2); + [...host.querySelectorAll('button')].find(b=>b.textContent?.includes('src · 2'))!.click();flushSync(); + expect(pending).toHaveLength(2); + selectMode('force');expect(pending).toHaveLength(3); + await unmount(mounted!);mounted=null;expect(pending[2]!.stopped).toBe(true); + } finally {globalThis.Worker=original;saveGraphHistory(location.href,{relationshipLayout:'default',layoutPositions:{},collapsedGroups:[],selected:null});} +}); describe('a long trail (#1976)', () => { it('keeps at most the 64 hops the store saves and /api/flow reads, dropping the oldest', () => { for (let i = 0; i < 70; i++) trail.push({ id: `function:h${i}` }); @@ -871,3 +1518,27 @@ describe('a long trail (#1976)', () => { expect(trail.hops.every((hop) => hop.name)).toBe(true); }); }); + + +it('keeps batched trail name requests scoped to the originating project', async () => { + const { fetchNodeRefs } = await import('../ui/src/lib/api'); + const ids = Array.from({ length: 64 }, (_, i) => `function:project-batch-${i}`); + let releaseFirst!: (result: WireNodeRefs) => void; + const previous = mockAdapter().adapter; + const next = mockAdapter().adapter; + const previousNodes = vi.fn() + .mockImplementationOnce(() => new Promise(resolve => { releaseFirst = resolve; })) + .mockResolvedValue({ items: [], missing: ids.slice(60) }); + const nextNodes = vi.fn().mockResolvedValue({ items: [], missing: [] }); + previous.nodes = previousNodes; + next.nodes = nextNodes; + setGraphAdapter(previous); + const signal = new AbortController().signal; + const pending = fetchNodeRefs(ids, signal); + setGraphAdapter(next); + releaseFirst({ items: [], missing: ids.slice(0, 60) }); + expect(await pending).toEqual({ items: [], missing: ids }); + expect(previousNodes.mock.calls.map(([batch]) => batch.length)).toEqual([60, 4]); + expect(previousNodes.mock.calls.every(([, passedSignal]) => passedSignal === signal)).toBe(true); + expect(nextNodes).not.toHaveBeenCalled(); +}); diff --git a/src/db/migrations.ts b/src/db/migrations.ts index bb7b84bf53..8cb733e1e2 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -9,7 +9,7 @@ import { SqliteDatabase } from './sqlite-adapter'; /** * Current schema version */ -export const CURRENT_SCHEMA_VERSION = 11; +export const CURRENT_SCHEMA_VERSION = 12; /** * Migration definition @@ -20,6 +20,28 @@ interface Migration { up: (db: SqliteDatabase) => void; } +/** Shared repair for upstream v10 and the fork that already used that version. */ +function migrateSynthesisInputs(db: SqliteDatabase): void { + db.exec(` + DROP INDEX IF EXISTS idx_nodes_kind; + CREATE INDEX idx_nodes_kind ON nodes(kind, file_path, start_line, id); + CREATE TABLE IF NOT EXISTS synthesis_inputs ( + file_path TEXT PRIMARY KEY REFERENCES files(path) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_edges_synthesis_site ON edges(CASE WHEN json_valid(metadata) THEN json_extract(metadata, '$.registeredAt') END) + WHERE CASE WHEN json_valid(metadata) THEN json_extract(metadata, '$.synthesizedBy') END IS NOT NULL; + UPDATE edges SET metadata = json_set(CASE WHEN json_valid(metadata) THEN metadata ELSE '{}' END, '$.synthesizedBy', 'go-method-contains') + WHERE kind = 'contains' AND provenance IS NULL AND EXISTS ( + SELECT 1 FROM nodes s JOIN nodes t ON t.id = edges.target + WHERE s.id = edges.source AND s.language = 'go' AND t.language = 'go' + AND s.kind IN ('struct', 'class', 'interface', 'enum', 'type_alias') AND t.kind = 'method' + AND s.file_path != t.file_path + ); + INSERT OR REPLACE INTO project_metadata(key, value, updated_at) + VALUES ('synthesis_pending', '1', 0); + `); +} + /** * All migrations in order * @@ -180,26 +202,7 @@ const migrations: Migration[] = [ { version: 10, description: 'Track synthesis inputs and stabilize synthesis traversal for incremental refresh (#1988)', - up: (db) => { - db.exec(` - DROP INDEX IF EXISTS idx_nodes_kind; - CREATE INDEX idx_nodes_kind ON nodes(kind, file_path, start_line, id); - CREATE TABLE IF NOT EXISTS synthesis_inputs ( - file_path TEXT PRIMARY KEY REFERENCES files(path) ON DELETE CASCADE - ); - CREATE INDEX IF NOT EXISTS idx_edges_synthesis_site ON edges(CASE WHEN json_valid(metadata) THEN json_extract(metadata, '$.registeredAt') END) - WHERE CASE WHEN json_valid(metadata) THEN json_extract(metadata, '$.synthesizedBy') END IS NOT NULL; - UPDATE edges SET metadata = json_set(CASE WHEN json_valid(metadata) THEN metadata ELSE '{}' END, '$.synthesizedBy', 'go-method-contains') - WHERE kind = 'contains' AND provenance IS NULL AND EXISTS ( - SELECT 1 FROM nodes s JOIN nodes t ON t.id = edges.target - WHERE s.id = edges.source AND s.language = 'go' AND t.language = 'go' - AND s.kind IN ('struct', 'class', 'interface', 'enum', 'type_alias') AND t.kind = 'method' - AND s.file_path != t.file_path - ); - INSERT OR REPLACE INTO project_metadata(key, value, updated_at) - VALUES ('synthesis_pending', '1', 0); - `); - }, + up: migrateSynthesisInputs, }, { version: 11, @@ -216,6 +219,24 @@ const migrations: Migration[] = [ `); }, }, + { + version: 12, + description: 'Reconcile legacy Pinable v10 and retain workbench cursor indexes', + up: (db) => { + // Pinable used v10 for pagination before upstream assigned v10 to + // synthesis. Its recorded version therefore skips upstream's v10. + // Inspect the schema rather than changing an already-applied history row. + const hasSynthesisInputs = db.prepare( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'synthesis_inputs'" + ).get(); + if (!hasSynthesisInputs) migrateSynthesisInputs(db); + db.exec(` + CREATE INDEX IF NOT EXISTS idx_edges_source_id ON edges(source, id); + CREATE INDEX IF NOT EXISTS idx_edges_target_id ON edges(target, id); + CREATE INDEX IF NOT EXISTS idx_nodes_file_id ON nodes(file_path, id); + `); + }, + }, ]; /** diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index d62a399592..4875f6f509 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -73,10 +73,12 @@ const NODE_REFS_PER_REQUEST = 60; * answers, so the ids go out in batches and the answers are merged (#1976). */ export async function fetchNodeRefs(ids: readonly string[], signal?: AbortSignal): Promise { - if (ids.length <= NODE_REFS_PER_REQUEST) return getGraphAdapter().nodes(ids, signal); + // A workspace may switch while a batch is in flight; do not mix projects. + const adapter = getGraphAdapter(); + if (ids.length <= NODE_REFS_PER_REQUEST) return adapter.nodes(ids, signal); const merged: WireNodeRefs = { items: [], missing: [] }; for (let i = 0; i < ids.length; i += NODE_REFS_PER_REQUEST) { - const batch = await getGraphAdapter().nodes(ids.slice(i, i + NODE_REFS_PER_REQUEST), signal); + const batch = await adapter.nodes(ids.slice(i, i + NODE_REFS_PER_REQUEST), signal); merged.items.push(...batch.items); merged.missing.push(...batch.missing); } From fe98febd8cce7944fbd817ffed68527ad515c0f3 Mon Sep 17 00:00:00 2001 From: Pinable test regression Date: Mon, 28 Sep 2026 02:56:17 +0000 Subject: [PATCH 2/2] test: wait for daemon attachment before connecting a raw client The proxy answers initialize before the daemon socket is listening, and daemon.pid is created before listen. Synchronize on the existing attachment signal rather than a guessed sleep. Preserve the real socket test, 1500ms shutdown bound, writer-lock assertion and platform conditions. Verified with 20 repetitions. --- __tests__/mcp-daemon.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/__tests__/mcp-daemon.test.ts b/__tests__/mcp-daemon.test.ts index eac53516f7..bcaeb63ab1 100644 --- a/__tests__/mcp-daemon.test.ts +++ b/__tests__/mcp-daemon.test.ts @@ -224,6 +224,9 @@ describe('Shared MCP daemon (issue #411)', () => { servers.push(server); sendInitialize(server.child, `file://${tempDir}`, 1); await waitFor(() => findResponse(server.stdout, 1), 10000); + // The proxy answers initialize locally and the pidfile precedes listen. + // Wait for an actual daemon handshake before opening the raw client. + await waitFor(() => server.stderr.some((l) => l.includes('Attached to shared daemon')), 10000); const pid = await waitFor(() => readLockPid(realRoot), 10000); const raw = net.connect(getDaemonSocketPath(realRoot)); try {