integrate train/misc: mermaid + layout-push + fs-search + markdown-images
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
// @vitest-environment jsdom
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useViewportSize } from '../src/client/breakpoints.ts'
|
||||
|
||||
describe('useViewportSize', () => {
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('publishes height-only resizes without requiring a breakpoint change', async () => {
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frames.push(callback)
|
||||
return frames.length
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn())
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 })
|
||||
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 })
|
||||
|
||||
const observed: string[] = []
|
||||
function Probe(): null {
|
||||
const size = useViewportSize()
|
||||
observed.push(`${size.width}x${size.height}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const host = document.createElement('div')
|
||||
document.body.append(host)
|
||||
const root = createRoot(host)
|
||||
await act(async () => { root.render(<Probe />) })
|
||||
|
||||
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 600 })
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
await act(async () => { frames.shift()?.(0) })
|
||||
|
||||
expect(observed.at(-1)).toBe('1280x600')
|
||||
await act(async () => { root.unmount() })
|
||||
})
|
||||
})
|
||||
+25
-3
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* fs-search: the host's recursive file-name search behind the editor side
|
||||
* panel's search box. Matches are case-insensitive name substrings, reported
|
||||
* RELATIVE to the root ('/'-separated); `.git` directories are skipped,
|
||||
* symlinked directories are never descended (cycle safety), and the
|
||||
* maxMatches/maxVisited budgets stop a runaway walk with `truncated: true`.
|
||||
* RELATIVE to the root ('/'-separated); noise directories (`.git`,
|
||||
* `node_modules`, build caches) are skipped, symlinked directories are
|
||||
* never descended (cycle safety), and the maxMatches/maxVisited budgets
|
||||
* stop a runaway walk with `truncated: true`.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
@@ -86,6 +87,27 @@ describe('fs-search', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('never descends into node_modules or other noise directories', async () => {
|
||||
const dir = makeFixture()
|
||||
try {
|
||||
mkdirSync(join(dir, 'node_modules', 'left-pad'), { recursive: true })
|
||||
mkdirSync(join(dir, 'web', 'dist'), { recursive: true })
|
||||
writeFileSync(join(dir, 'node_modules', 'left-pad', 'guide.md'), 'dep')
|
||||
writeFileSync(join(dir, 'web', 'dist', 'bundle.js'), 'build')
|
||||
writeFileSync(join(dir, 'web', 'app.ts'), 'src')
|
||||
// A match hidden behind node_modules / dist must not appear; project
|
||||
// files after those forests must still be reachable within budget.
|
||||
expect((await searchFiles(dir, 'guide')).matches).toEqual(['docs/guide.md'])
|
||||
expect((await searchFiles(dir, 'left-pad')).matches).toEqual([])
|
||||
expect((await searchFiles(dir, 'bundle')).matches).toEqual([])
|
||||
expect((await searchFiles(dir, 'app.ts')).matches).toEqual(['web/app.ts'])
|
||||
expect((await searchFiles(dir, 'node_modules')).matches).toEqual([])
|
||||
expect((await searchFiles(dir, 'dist')).matches).toEqual([])
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('an empty (or whitespace) query matches nothing without walking', async () => {
|
||||
const dir = makeFixture()
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* layout.css must let the DSH conversation column shrink below its content
|
||||
* size. Without min-height:0 a long unbreakable URL grows the grid item
|
||||
* past the viewport and clips the composer.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const css = readFileSync('src/client/layout.css', 'utf8')
|
||||
|
||||
describe('layout.css conversation column', () => {
|
||||
it('targets the AppFrame center column', () => {
|
||||
expect(css).toContain('[data-pane="conversation"]')
|
||||
expect(css).toContain('[data-slot="conversation"]')
|
||||
})
|
||||
|
||||
it('allows the center column to shrink and wrap long tokens', () => {
|
||||
expect(css).toMatch(/min-height:\s*0/)
|
||||
expect(css).toMatch(/overflow-wrap:\s*anywhere/)
|
||||
})
|
||||
|
||||
it('leaves overflow ownership to the host conversation descendants', () => {
|
||||
const conversationRule = css.match(
|
||||
/#root \[data-dsh-frame\][\s\S]*?\{([\s\S]*?)\n\}/,
|
||||
)?.[1]
|
||||
expect(conversationRule).toBeDefined()
|
||||
expect(conversationRule).not.toMatch(/(?:^|[;\s])overflow\s*:/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Layout-push size contract: the conversation column (output + composer)
|
||||
* must keep at least PANEL_MIN of the viewport after the bottom panel
|
||||
* claims its height. A stale persisted bottomHeight (or a cap at 100vh)
|
||||
* used to crush the composer off-screen.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BOTTOM_MIN, PANEL_MIN } from '../src/client/state.ts'
|
||||
import { layoutPushSize } from '../src/client/layout-push.ts'
|
||||
|
||||
describe('layoutPushSize', () => {
|
||||
it('pushes nothing on a narrow drawer viewport', () => {
|
||||
expect(layoutPushSize({
|
||||
narrow: true,
|
||||
panelOpen: true,
|
||||
bottomOpen: true,
|
||||
width: 400,
|
||||
bottomHeight: 400,
|
||||
viewportWidth: 390,
|
||||
viewportHeight: 700,
|
||||
})).toEqual({ width: 0, height: 0 })
|
||||
})
|
||||
|
||||
it('pushes nothing when both panels are collapsed', () => {
|
||||
expect(layoutPushSize({
|
||||
narrow: false,
|
||||
panelOpen: false,
|
||||
bottomOpen: false,
|
||||
width: 400,
|
||||
bottomHeight: 400,
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 800,
|
||||
})).toEqual({ width: 0, height: 0 })
|
||||
})
|
||||
|
||||
it('caps an oversize bottom panel so the conversation keeps PANEL_MIN', () => {
|
||||
const viewportHeight = 800
|
||||
const size = layoutPushSize({
|
||||
narrow: false,
|
||||
panelOpen: false,
|
||||
bottomOpen: true,
|
||||
width: 0,
|
||||
bottomHeight: 10_000,
|
||||
viewportWidth: 1280,
|
||||
viewportHeight,
|
||||
})
|
||||
expect(size.height).toBe(viewportHeight - PANEL_MIN)
|
||||
expect(size.height).toBeGreaterThanOrEqual(BOTTOM_MIN)
|
||||
expect(viewportHeight - size.height).toBeGreaterThanOrEqual(PANEL_MIN)
|
||||
})
|
||||
|
||||
it('caps an oversize right panel at the viewport width', () => {
|
||||
expect(layoutPushSize({
|
||||
narrow: false,
|
||||
panelOpen: true,
|
||||
bottomOpen: false,
|
||||
width: 10_000,
|
||||
bottomHeight: 0,
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 800,
|
||||
})).toEqual({ width: 1280, height: 0 })
|
||||
})
|
||||
|
||||
it('passes through in-range open sizes', () => {
|
||||
expect(layoutPushSize({
|
||||
narrow: false,
|
||||
panelOpen: true,
|
||||
bottomOpen: true,
|
||||
width: 400,
|
||||
bottomHeight: 220,
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 800,
|
||||
})).toEqual({ width: 400, height: 220 })
|
||||
})
|
||||
|
||||
it('never pushes beyond a viewport smaller than the normal panel minima', () => {
|
||||
expect(layoutPushSize({
|
||||
narrow: false,
|
||||
panelOpen: true,
|
||||
bottomOpen: true,
|
||||
width: 400,
|
||||
bottomHeight: 220,
|
||||
viewportWidth: 200,
|
||||
viewportHeight: 200,
|
||||
})).toEqual({ width: 200, height: 0 })
|
||||
})
|
||||
|
||||
it('turns non-finite geometry into a safe zero push', () => {
|
||||
expect(layoutPushSize({
|
||||
narrow: false,
|
||||
panelOpen: true,
|
||||
bottomOpen: true,
|
||||
width: Number.NaN,
|
||||
bottomHeight: Number.POSITIVE_INFINITY,
|
||||
viewportWidth: Number.NaN,
|
||||
viewportHeight: Number.POSITIVE_INFINITY,
|
||||
})).toEqual({ width: 0, height: 0 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { rewriteLocalImageUrls } from '../src/client/markdown-images.ts'
|
||||
import type { SessionScope } from '../src/client/api.ts'
|
||||
|
||||
const ORIGIN = 'http://127.0.0.1:3080'
|
||||
const scope: SessionScope = { sessionId: 'abc', cwd: '/repo' }
|
||||
|
||||
describe('rewriteLocalImageUrls', () => {
|
||||
it('resolves relative destinations against the opened file directory', () => {
|
||||
const md = '\n'
|
||||
const out = rewriteLocalImageUrls(md, scope, '/repo/docs/readme.md', ORIGIN)
|
||||
expect(out).toContain(``)
|
||||
expect(out).toContain(``)
|
||||
})
|
||||
|
||||
it('expects the rewritten destination to be an absolute http URL MarkdownText accepts', () => {
|
||||
const out = rewriteLocalImageUrls('', scope, '/repo/readme.md', ORIGIN)
|
||||
const url = out.match(/\]\((http[^)]+)\)/)?.[1]
|
||||
expect(url).toBeDefined()
|
||||
expect(new URL(url as string).protocol).toBe('http:')
|
||||
})
|
||||
|
||||
it('passes absolute local paths through unchanged', () => {
|
||||
const md = ''
|
||||
const out = rewriteLocalImageUrls(md, scope, '/repo/docs/readme.md', ORIGIN)
|
||||
expect(out).toContain(``)
|
||||
})
|
||||
|
||||
it('keeps remote http(s)/data/mailto destinations untouched', () => {
|
||||
const md = '\n\n'
|
||||
const out = rewriteLocalImageUrls(md, scope, '/repo/readme.md', ORIGIN)
|
||||
expect(out).toContain('')
|
||||
expect(out).toContain('')
|
||||
expect(out).toContain('')
|
||||
})
|
||||
|
||||
it('leaves in-page anchors untouched', () => {
|
||||
const md = '\n\n[link](#top)'
|
||||
const out = rewriteLocalImageUrls(md, scope, '/repo/readme.md', ORIGIN)
|
||||
expect(out).toContain('')
|
||||
})
|
||||
|
||||
it('rewrites reference-style image definitions', () => {
|
||||
const md = '![logo][logo]\n\n[logo]: ./logo.svg'
|
||||
const out = rewriteLocalImageUrls(md, scope, '/repo/readme.md', ORIGIN)
|
||||
expect(out).toContain(`[logo]: ${ORIGIN}/sidebar/file?sessionId=abc&path=%2Frepo%2Flogo.svg&cwd=%2Frepo`)
|
||||
// The inline reference itself is left for MarkdownText to combine.
|
||||
expect(out).toContain('![logo][logo]')
|
||||
})
|
||||
|
||||
it('normalizes `.`/`..` segments in relative destinations', () => {
|
||||
const md = '\n'
|
||||
const out = rewriteLocalImageUrls(md, scope, '/repo/docs/sub/readme.md', ORIGIN)
|
||||
expect(out).toContain(``)
|
||||
expect(out).toContain(``)
|
||||
expect(out).not.toContain('/.')
|
||||
})
|
||||
|
||||
it('drops the cwd param when the scope has no cwd', () => {
|
||||
const out = rewriteLocalImageUrls('', { sessionId: 'abc' }, '/repo/readme.md', ORIGIN)
|
||||
expect(out).toContain(`sessionId=abc&path=%2Frepo%2Fimg.png`)
|
||||
expect(out).not.toContain('cwd=')
|
||||
})
|
||||
|
||||
it('handles windows absolute paths as local (not remote)', () => {
|
||||
const md = ''
|
||||
const out = rewriteLocalImageUrls(md, scope, 'C:\\repo\\docs\\readme.md', ORIGIN)
|
||||
// A Windows drive path is absolute (not a remote scheme) → rewritten.
|
||||
const url = out.match(/\]\(([^)]+)\)/)?.[1] as string
|
||||
expect(url).toContain('/sidebar/file?')
|
||||
expect(decodeURIComponent(url)).toContain('C:\\repo\\img.png')
|
||||
})
|
||||
})
|
||||
@@ -16,13 +16,14 @@ import { act } from 'react-dom/test-utils'
|
||||
|
||||
vi.mock('mermaid', () => ({
|
||||
default: {
|
||||
initialize: () => {},
|
||||
initialize: vi.fn(),
|
||||
render: async () => ({
|
||||
svg: '<svg xmlns="http://www.w3.org/2000/svg"><text>MOCK-DIAGRAM</text></svg>',
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
import mermaid from 'mermaid'
|
||||
import { MermaidMarkdown } from '../src/client/mermaid.tsx'
|
||||
|
||||
const codeLabels = { copyLabel: 'Copy', copiedLabel: 'Copied' }
|
||||
@@ -56,6 +57,15 @@ describe('MermaidMarkdown', () => {
|
||||
await unmount(root)
|
||||
})
|
||||
|
||||
it('suppresses Mermaid global error rendering', async () => {
|
||||
vi.mocked(mermaid.initialize).mockClear()
|
||||
const { root } = await renderMarkdown('```mermaid\ngraph TD\n A-->B\n```')
|
||||
expect(mermaid.initialize).toHaveBeenCalledWith(expect.objectContaining({
|
||||
suppressErrorRendering: true,
|
||||
}))
|
||||
await unmount(root)
|
||||
})
|
||||
|
||||
it('resolves cross-fence reference-style links (single markdown parse)', async () => {
|
||||
const text = [
|
||||
'[before][shared]',
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync('src/client/Sidebar.tsx', 'utf8')
|
||||
|
||||
describe('Sidebar layout-push integration', () => {
|
||||
it('does not bypass the shared bottom-height cap during width drags', () => {
|
||||
expect(source).not.toContain('Math.min(state.bottomHeight, window.innerHeight)')
|
||||
expect(source.match(/pushedBottomHeight\(/g)).toHaveLength(9)
|
||||
})
|
||||
|
||||
it('caps panel geometry against the viewport visible above the keyboard', () => {
|
||||
expect(source).toContain('setVisualViewportHeight(Math.max(0, Math.round(vv.height)))')
|
||||
expect(source).toContain('visualViewportHeight ?? viewport.height')
|
||||
expect(source.match(/viewportHeight: layoutViewportHeight/g)).toHaveLength(4)
|
||||
expect(source).toContain('viewport.width, layoutViewportHeight, keyboardInset]')
|
||||
})
|
||||
|
||||
it('adds the keyboard inset to the conversation push, not the panel height', () => {
|
||||
expect(source.match(/height \+ keyboardInset/g)).toHaveLength(2)
|
||||
expect(source).toContain('height: bottomPanelHeight')
|
||||
expect(source).not.toContain('height: bottomPanelHeight + keyboardInset')
|
||||
})
|
||||
|
||||
it('reapplies the visible-height cap to every vertical drag result', () => {
|
||||
expect(source).not.toMatch(/const height = clampHeight\(/)
|
||||
expect(source).not.toMatch(/height = clampHeight\(/)
|
||||
expect(source.match(/pushedBottomHeight\(true, clampHeight\(/g)).toHaveLength(6)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user