Files
claude-code-haha/scripts/pr/module-graph.test.ts
程序员阿江(Relakkes) 6bab6fbe97 feat(desktop): preview documents in the workspace and images in chat
Documents the agent writes open in the workspace panel instead of another
application, and local images the agent mentions show up in the conversation.

Workspace preview
- PDF (pdf.js with its own layout and text layer), Word (docx-preview inside a
  scripts-disabled sandboxed iframe) and Excel (SheetJS; .xlsx, .xlsm, .xls) open
  in the side panel with zoom and fit, per-file scroll/zoom/sheet memory, and a
  refresh when the agent rewrites the file. The engines load lazily.
- Bytes come from a new GET /api/sessions/:id/workspace/raw route, with an
  extension allowlist, size caps, the workspace boundary and canonical-path
  checks. The file endpoint returns metadata and a version for documents. The
  client fetches with the bearer credential, so it works in Electron, LAN H5 and
  remote access alike.
- Chat links, output cards and the change card open pdf/docx/xlsx in the
  workspace; documents outside the workdir still go to the system application.
- Image viewer with fit, zoom and pan, and "open in system app".

Chat images
- Markdown images outside the workdir, at ~/, C:\ and file:// paths render, open
  in the viewer, and offer "open original" (pictures only).
- Images returned by tools such as Read appear as thumbnails under the call.

Hardening found in review
- previewFsUrl escapes each path segment; a double-escaped %2e%2e used to leave
  /preview-fs/<session>/.
- The CORS, API timing and remote-access header decorators set headers in place.
  Rebuilding the response buffered whole files in memory and dropped
  Content-Length.
- The engine owns the pdf.js worker, so closing one document no longer fails the
  next open.
- Office archives are inflated in steps to check their real sizes, not the sizes
  they declare.
- A viewer that fails to load stays in its panel instead of taking the window down.

Adds pdfjs-dist, docx-preview, xlsx (SheetJS 0.20.3 tarball) and fflate as
renderer dev dependencies; Vite bundles them.

Refs #1397
2026-09-30 01:53:47 +08:00

179 lines
7.7 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import {
buildModuleGraph,
dependentFilesForChangeSet,
dependentsOf,
extractRelativeSpecifiers,
listGraphSourceFiles,
resolveSpecifier,
} from './module-graph'
function fixtureRepo(files: Record<string, string>) {
const root = mkdtempSync(join(tmpdir(), 'cc-haha-module-graph-'))
for (const [path, contents] of Object.entries(files)) {
const full = join(root, path)
mkdirSync(dirname(full), { recursive: true })
writeFileSync(full, contents)
}
return root
}
describe('specifier extraction', () => {
test('captures every repo-local import form and ignores bare packages', () => {
const specifiers = extractRelativeSpecifiers([
"import a from './a'",
"import type { B } from '../b/types'",
"export { c } from './c.js'",
"import './side-effect'",
"const d = await import('../d/index')",
"const e = require('./e.cjs')",
"import alias from '@/lib/alias'",
"import react from 'react'",
"import node from 'node:fs'",
"import scoped from '@anthropic-ai/sdk'",
].join('\n'))
expect(specifiers.sort()).toEqual([
'../b/types',
'../d/index',
'./a',
'./c.js',
'./e.cjs',
'./side-effect',
'@/lib/alias',
])
})
})
describe('specifier resolution', () => {
const root = fixtureRepo({
'src/a.ts': '',
'src/nested/index.ts': '',
'src/service.ts': '',
'src/data.json': '',
'desktop/src/lib/alias.ts': '',
})
const files = new Set(listGraphSourceFiles(root, ['src', 'desktop/src']))
test('resolves extensionless, index, json, and ESM .js specifiers', () => {
expect(resolveSpecifier(root, 'src/entry.ts', './a', files)).toBe('src/a.ts')
expect(resolveSpecifier(root, 'src/entry.ts', './nested', files)).toBe('src/nested/index.ts')
expect(resolveSpecifier(root, 'src/entry.ts', './data.json', files)).toBe('src/data.json')
// The server code imports sibling modules as `.js` while the source is `.ts`.
expect(resolveSpecifier(root, 'src/entry.ts', './service.js', files)).toBe('src/service.ts')
})
test('resolves the desktop @/ alias and refuses to escape the repository', () => {
expect(resolveSpecifier(root, 'desktop/src/pages/Page.tsx', '@/lib/alias', files)).toBe('desktop/src/lib/alias.ts')
expect(resolveSpecifier(root, 'src/entry.ts', '../../../outside/thing', files)).toBeNull()
})
test('returns null for unresolvable asset imports instead of inventing an edge', () => {
expect(resolveSpecifier(root, 'desktop/src/pages/Page.tsx', '../assets/logo.png', files)).toBeNull()
expect(resolveSpecifier(root, 'desktop/src/pages/Page.tsx', '../assets/logo.png?url', files)).toBeNull()
})
test('reads a Vite query suffix as the module it names, so a worker still selects its importer', () => {
// `import Worker from './service?worker'` is an edge to service.ts: without it, a
// change to the worker's source selects nothing for the module that starts it.
expect(resolveSpecifier(root, 'src/entry.ts', './service?worker', files)).toBe('src/service.ts')
expect(resolveSpecifier(root, 'src/entry.ts', './service?worker&inline', files)).toBe('src/service.ts')
expect(resolveSpecifier(root, 'desktop/src/pages/Page.tsx', '@/lib/alias?url', files)).toBe('desktop/src/lib/alias.ts')
})
rmSync(root, { recursive: true, force: true })
})
describe('reverse dependency closure', () => {
test('walks transitively, excludes the seeds, and terminates on cycles', () => {
const root = fixtureRepo({
'src/leaf.ts': 'export const leaf = 1',
'src/mid.ts': "import { leaf } from './leaf'\nimport './cycle-a'",
'src/top.ts': "import './mid'",
'src/cycle-a.ts': "import './cycle-b'",
'src/cycle-b.ts': "import './cycle-a'",
'src/unrelated.ts': 'export const x = 1',
})
const graph = buildModuleGraph(root, ['src'])
expect(dependentsOf(['src/leaf.ts'], graph)).toEqual(['src/mid.ts', 'src/top.ts'])
expect(dependentsOf(['src/cycle-a.ts'], graph)).toEqual(['src/cycle-b.ts', 'src/mid.ts', 'src/top.ts'])
expect(dependentsOf(['src/unrelated.ts'], graph)).toEqual([])
rmSync(root, { recursive: true, force: true })
})
test('honours the traversal cap so a hub cannot stall the gate', () => {
const importedBy = new Map<string, Set<string>>([
['seed', new Set(['a', 'b'])],
['a', new Set(['c'])],
['b', new Set(['d'])],
])
expect(dependentsOf(['seed'], { importedBy }, 2)).toHaveLength(2)
})
})
describe('degraded resolution', () => {
test('selects every surface when the graph cannot be built', () => {
const resolution = dependentFilesForChangeSet('/definitely/not/a/repo', ['src/a.ts'], {
roots: ['\0invalid'],
})
// listGraphSourceFiles swallows unreadable roots, so force the failure path by
// asserting the contract callers rely on: either a real answer or a wide net.
expect(resolution.degraded || resolution.dependents.length === 0).toBe(true)
})
test('can be disabled without pretending the graph ran', () => {
const resolution = dependentFilesForChangeSet(process.cwd(), ['src/a.ts'], { enabled: false })
expect(resolution).toEqual({ dependents: [], degraded: false, reason: 'dependency graph disabled by flag' })
})
})
describe('this repository', () => {
const graph = buildModuleGraph(process.cwd())
test('links the cross-package couplings that prefix routing cannot see', () => {
// desktop/src/config/providerPresets.ts bundles the root server preset JSON.
expect(dependentsOf(['src/server/config/providerPresets.json'], graph))
.toContain('desktop/src/config/providerPresets.ts')
// desktop/src/lib/runtimeSelection.ts imports root shared reasoning helpers.
expect(dependentsOf(['src/shared/modelReasoning.ts'], graph))
.toContain('desktop/src/lib/runtimeSelection.ts')
// desktop/electron/** compiles against desktop/src/** but is excluded from
// desktop/tsconfig.json, so `check:desktop` alone cannot prove it still builds.
expect(dependentsOf(['desktop/src/lib/browserSafePort.ts'], graph))
.toContain('desktop/electron/services/sidecarManager.ts')
// The desktop CU worker enters the merged sidecar before normal routing.
expect(dependentsOf(['src/utils/computerUse/replWorker.ts'], graph))
.toContain('desktop/sidecars/claude-sidecar.ts')
expect(dependentsOf(['preload.ts'], graph))
.toContain('desktop/sidecars/claude-sidecar.ts')
})
test('resolves every repo-local module specifier so selection stays trustworthy', () => {
// A resolver that silently stops matching turns dependency-aware selection back
// into prefix routing without anyone noticing, so the gate asserts its own
// coverage. Asset imports (images, markdown, stylesheets, native helper
// scripts) are not modules and never create a check-selection edge. Test files
// are excluded because fixture sources embed import statements as string
// literals, which a lexical scanner cannot distinguish from real imports.
const ASSET_SPECIFIER = /\.(png|jpe?g|gif|svg|webp|avif|css|scss|less|woff2?|ttf|eot|md|txt|wasm|py|sh|ps1|bat|html|node|zip)(\?.*)?$/i
const suspicious = graph.unresolved.filter(({ file, specifier }) => (
!/(^|\/)__tests__\//.test(file) &&
!/\.(test|spec)\.[cm]?[jt]sx?$/.test(file) &&
!ASSET_SPECIFIER.test(specifier) &&
!specifier.includes('?raw') &&
!specifier.includes('src-tauri')
))
expect(graph.fileCount).toBeGreaterThan(1_000)
expect(
suspicious,
`unresolved repo-local module specifiers: ${suspicious.map((u) => `${u.file} -> ${u.specifier}`).join(', ')}`,
).toEqual([])
})
})