fix(sidebar): 二轮独立验证的 9 项缺陷 + 目录级暂存
verifier 二轮报告(F1–F9)全部修复,每条都有「临时回退即变红」的判别性验证: - F1【高】中文等非 latin1 下载名 → 真实宿主 500:content-disposition 改用 RFC 5987(ASCII 回退 + filename*=UTF-8'');补真 node:http 用例与根因对照断言 - F2【中】多选同名文件归档重名(解压互相覆盖):冲突项逐级上溯父目录消歧 - F3【中低】maxBytes 在读完之后才判定;budget<=0 的 0 字节 FIFO 会永久阻塞: 改为先 stat、读取前拒绝(FIFO 探针验证) - F4【中低】SSH 模式 + 宿主不可用时 reveal 整条消失:保留 kind==='reveal' 目标 - F5【低】图钉嵌套交互语义非法且键盘不可达:改为诚实的鼠标热区,键盘入口归设置面板 - F6【低】变更树空闲 tick 全量重渲染:行 memo + 恒定 props + 内容键派生 + 共享 store 的 sameStatus 保住快照身份 - F7【低】排序随运行时 locale 漂移:显式 'en' collation + 码点兜底 - F8【低】POSIX 文件名的反斜杠被当成层级:不再替换 - F9【低】打包中重复点击静默、无反馈:busy 行(role=status)+ in-flight 守卫 - 新增:目录行「暂存/取消暂存该目录」(git pathspec 语义,不触发折叠) 验证:typecheck / lint / test(134 文件 1441 通过)/ build 全绿; DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过
This commit is contained in:
@@ -250,6 +250,31 @@ describe('change tree (the Git lens reads its groups through it)', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('pins the collation, so the order cannot move with the runtime locale', () => {
|
||||
// Mixed case + accents + non-letters: the comparator runs an explicit
|
||||
// 'en' base collation and breaks ties by code point. A bare
|
||||
// `localeCompare(other)` would follow the machine's ICU default and could
|
||||
// reorder the same change list on another machine.
|
||||
const names = ['b.ts', 'A.ts', 'ä.ts', 'Z.ts', 'a.ts', 'Ä.ts', '_x.ts', '1.ts']
|
||||
const order = (list: readonly string[]): string[] =>
|
||||
buildChangeTree(list.map(path => entry(path))).map(node => node.name)
|
||||
expect(order(names)).toEqual(['_x.ts', '1.ts', 'A.ts', 'a.ts', 'Ä.ts', 'ä.ts', 'b.ts', 'Z.ts'])
|
||||
// …and it is a TOTAL order: reversing the input cannot move a row.
|
||||
expect(order([...names].reverse())).toEqual(order(names))
|
||||
|
||||
const dirs = ['Zdir/f.ts', 'adir/f.ts', 'Ädir/f.ts', '_dir/f.ts']
|
||||
const dirOrder = (list: readonly string[]): string[] =>
|
||||
buildChangeTree(list.map(path => entry(path))).map(node => node.name)
|
||||
expect(dirOrder(dirs)).toEqual(['_dir', 'adir', 'Ädir', 'Zdir'])
|
||||
expect(dirOrder([...dirs].reverse())).toEqual(dirOrder(dirs))
|
||||
|
||||
// Why the pin is load-bearing: under Swedish collation 'ä' sorts AFTER
|
||||
// 'z', so an unpinned comparator would order this very list differently on
|
||||
// a machine whose runtime locale happens to be Swedish.
|
||||
expect(['z.ts', 'ä.ts'].sort((left, right) => left.localeCompare(right, 'sv', { sensitivity: 'base' })))
|
||||
.toEqual(['z.ts', 'ä.ts'])
|
||||
})
|
||||
|
||||
it('returns no nodes for an empty or all-clean list', () => {
|
||||
expect(buildChangeTree([])).toEqual([])
|
||||
expect(buildChangeTree([entry('a.ts', ' '), entry('b.ts', '!!')])).toEqual([])
|
||||
|
||||
@@ -314,4 +314,44 @@ describe('FileTree zip and download', () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
await settleDownload()
|
||||
})
|
||||
|
||||
it('shows a loading line while the archive is being built, and hides it after', async () => {
|
||||
let release: (response: Response) => void = () => {}
|
||||
fetchMock.mockImplementation(async () => await new Promise<Response>(resolve => { release = resolve }))
|
||||
harness = await mountTree()
|
||||
const busyLine = (): HTMLElement | null => harness.container.querySelector<HTMLElement>('[data-kind="loading"]')
|
||||
// Nothing is shown before the request starts…
|
||||
expect(busyLine()).toBeNull()
|
||||
rightClick(rowByName(harness.container, 'sub'))
|
||||
clickMenuitem('Zip and download')
|
||||
// …the in-flight request keeps one visible, announced line…
|
||||
const line = busyLine()
|
||||
expect(line).not.toBeNull()
|
||||
expect(line?.textContent).toBe('Loading…')
|
||||
expect(line?.getAttribute('role')).toBe('status')
|
||||
// …a repeated pick is still guarded (no second fetch) AND still not silent.
|
||||
rightClick(rowByName(harness.container, 'sub'))
|
||||
clickMenuitem('Zip and download')
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(busyLine()).not.toBeNull()
|
||||
await act(async () => { release(okResponse()) })
|
||||
await settleDownload()
|
||||
expect(busyLine()).toBeNull()
|
||||
expect(downloads).toEqual(['blob:mock-1|sub.zip'])
|
||||
})
|
||||
|
||||
it('hides the loading line when the archive fails too', async () => {
|
||||
let release: (response: Response) => void = () => {}
|
||||
fetchMock.mockImplementation(async () => await new Promise<Response>(resolve => { release = resolve }))
|
||||
harness = await mountTree()
|
||||
rightClick(rowByName(harness.container, 'sub'))
|
||||
clickMenuitem('Zip and download')
|
||||
expect(harness.container.querySelector('[data-kind="loading"]')).not.toBeNull()
|
||||
await act(async () => { release(errorResponse(413, { ok: false, error: { message: 'too big' } })) })
|
||||
await settleDownload()
|
||||
expect(harness.container.querySelector('[data-kind="loading"]')).toBeNull()
|
||||
// The busy line yields to the error strip: both never race for the slot.
|
||||
expect(harness.container.querySelector('[role="alert"]')?.textContent)
|
||||
.toContain('Archive failed: too big')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -162,11 +162,31 @@ describe('FileTree plugin open-with menu', () => {
|
||||
'File Manager', 'VS Code', 'Cursor', 'Zed', 'Windsurf',
|
||||
])
|
||||
expect(submenuRows().every(item => item.querySelector('[class*="openWithPin"]') !== null)).toBe(true)
|
||||
// Pinned state is announced per row (and swaps the pushpin glyph).
|
||||
// Pinned state is carried per row by the pin's TITLE (it swaps the glyph
|
||||
// and the hover hint).
|
||||
const vscodeRow = submenuRows().find(item => item.textContent?.trim() === 'VS Code')
|
||||
const cursorRow = submenuRows().find(item => item.textContent?.trim() === 'Cursor')
|
||||
expect(vscodeRow?.querySelector('[aria-label="Unpin"]')).not.toBeNull()
|
||||
expect(cursorRow?.querySelector('[aria-label="Pin to menu"]')).not.toBeNull()
|
||||
expect(vscodeRow?.querySelector('[title="Unpin"]')).not.toBeNull()
|
||||
expect(cursorRow?.querySelector('[title="Pin to menu"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the pin a mouse hot zone, never a fake nested control', async () => {
|
||||
// The Menu renders each row as `<button role="menuitem">`, so ANY
|
||||
// role=button/tabIndex on the pin would be invalid nesting AND
|
||||
// unreachable by keyboard (the row button owns focus). It is deliberately
|
||||
// a plain span: the pin's nameable affordance is its `title`, and the
|
||||
// reachable path to the same pinned list is the settings panel.
|
||||
harness = await mountTree()
|
||||
openMenu(harness.container)
|
||||
act(() => { submenuParent().click() })
|
||||
const vscodePin = submenuRows().find(item => item.textContent?.trim() === 'VS Code')!
|
||||
.querySelector<HTMLElement>('[class*="openWithPin"]')!
|
||||
expect(vscodePin.tagName).toBe('SPAN')
|
||||
expect(vscodePin.getAttribute('role')).toBeNull()
|
||||
expect(vscodePin.hasAttribute('tabindex')).toBe(false)
|
||||
expect(vscodePin.getAttribute('title')).toBe('Unpin')
|
||||
// The row itself stays the only interactive element of the entry.
|
||||
expect(vscodePin.closest('[role="menuitem"]')?.tagName).toBe('BUTTON')
|
||||
})
|
||||
|
||||
it('pin click toggles without selecting the row or closing the menu', async () => {
|
||||
@@ -195,16 +215,20 @@ describe('FileTree plugin open-with menu', () => {
|
||||
expect(menuItems()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('appends the SSH hint to VSCode-family labels in remote mode', async () => {
|
||||
it('appends the SSH hint to VSCode-family labels in remote mode (reveal stays)', async () => {
|
||||
// The SSH-resolved list keeps `explorer` (see resolveOpenWithTargets):
|
||||
// reveal runs on the machine that owns the workspace, and it is the only
|
||||
// reveal left when the DSH host's open-in-app capability is unavailable.
|
||||
// Dropping it here would test a list the caller never produces.
|
||||
harness = await mountTree({
|
||||
openWithSsh: true,
|
||||
openWithTargets: targets.filter(target => !target.localOnly),
|
||||
openWithTargets: targets.filter(target => !target.localOnly || target.kind === 'reveal'),
|
||||
openWithPinned: [],
|
||||
})
|
||||
openMenu(harness.container)
|
||||
act(() => { submenuParent().click() })
|
||||
expect(submenuRows().map(item => item.textContent?.trim())).toEqual([
|
||||
'VS Code (SSH)', 'Cursor (SSH)', 'Windsurf (SSH)',
|
||||
'File Manager', 'VS Code (SSH)', 'Cursor (SSH)', 'Windsurf (SSH)',
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -17,7 +17,9 @@ import { act } from 'react-dom/test-utils'
|
||||
import { GitLens } from '../src/client/changes/GitLens.tsx'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
import { api, type GitLogEntry, type GitStatusResult, type GitWorktree } from '../src/client/api.ts'
|
||||
import type { BetterSidebarService } from '../src/client/service.ts'
|
||||
import { t } from '../src/client/locales.ts'
|
||||
import type { Context } from '../src/context-types.ts'
|
||||
|
||||
import { setupReactAct } from './test-utils.ts'
|
||||
setupReactAct()
|
||||
@@ -70,7 +72,12 @@ async function flushEffects(): Promise<void> {
|
||||
* — the shared status store only polls/loads while a consumer is on screen. */
|
||||
function mountGit(
|
||||
root: Root,
|
||||
options: { scope?: { sessionId: string; cwd?: string }; refreshTick?: number; visible?: boolean } = {},
|
||||
options: {
|
||||
scope?: { sessionId: string; cwd?: string }
|
||||
refreshTick?: number
|
||||
visible?: boolean
|
||||
ctx?: Context
|
||||
} = {},
|
||||
): void {
|
||||
act(() => {
|
||||
root.render(createElement(GitLens, {
|
||||
@@ -81,6 +88,7 @@ function mountGit(
|
||||
selectedRef: null,
|
||||
visible: options.visible ?? true,
|
||||
refreshTick: options.refreshTick ?? 0,
|
||||
...(options.ctx === undefined ? {} : { ctx: options.ctx }),
|
||||
}))
|
||||
})
|
||||
}
|
||||
@@ -538,10 +546,14 @@ describe('GitLens (changes tab, git lens) change tree', () => {
|
||||
expect(unstaged.querySelector('[data-count]')?.textContent).toBe('2')
|
||||
expect(staged.querySelector('[data-count]')?.textContent).toBe('1')
|
||||
|
||||
const stageAll = unstaged.querySelector<HTMLButtonElement>(`button[aria-label="${t('stageAll')}"]`)
|
||||
const unstageAll = staged.querySelector<HTMLButtonElement>(`button[aria-label="${t('unstageAll')}"]`)
|
||||
expect(stageAll).not.toBeNull()
|
||||
expect(unstageAll).not.toBeNull()
|
||||
// The band's own action is the one that is NOT inside a row (directory
|
||||
// rows carry the same "Stage all" label for their own subtree).
|
||||
const stageAll = [...unstaged.querySelectorAll<HTMLButtonElement>(`button[aria-label="${t('stageAll')}"]`)]
|
||||
.find(button => button.closest('[data-row]') === null)
|
||||
const unstageAll = [...staged.querySelectorAll<HTMLButtonElement>(`button[aria-label="${t('unstageAll')}"]`)]
|
||||
.find(button => button.closest('[data-row]') === null)
|
||||
expect(stageAll).not.toBeUndefined()
|
||||
expect(unstageAll).not.toBeUndefined()
|
||||
|
||||
const stage = vi.spyOn(api, 'gitStage').mockResolvedValue({ ok: true })
|
||||
await act(async () => { stageAll!.click() })
|
||||
@@ -553,4 +565,110 @@ describe('GitLens (changes tab, git lens) change tree', () => {
|
||||
container.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('stages and unstages a whole directory from its row action', async () => {
|
||||
const { container, root } = makeRoot()
|
||||
try {
|
||||
await mountTree(container, root, [
|
||||
{ path: 'src/deep/a.ts', xy: ' M' }, // unstaged → the 'src/deep' row
|
||||
{ path: 'docs/b.md', xy: 'M ' }, // staged → the 'docs' row
|
||||
])
|
||||
const stage = vi.spyOn(api, 'gitStage').mockResolvedValue({ ok: true })
|
||||
const unstage = vi.spyOn(api, 'gitUnstage').mockResolvedValue({ ok: true })
|
||||
|
||||
const dirAction = (path: string, label: string): HTMLButtonElement => {
|
||||
const row = container.querySelector<HTMLElement>(`[data-row="${path}"]`)
|
||||
expect(row).not.toBeNull()
|
||||
const button = row!.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)
|
||||
expect(button).not.toBeNull()
|
||||
return button!
|
||||
}
|
||||
|
||||
const unstagedDir = dirAction('src/deep', t('stageAll'))
|
||||
await act(async () => { unstagedDir.click() })
|
||||
await flushEffects()
|
||||
// The directory path stages the whole subtree (`git add -A -- <dir>`).
|
||||
expect(stage).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'session' }), 'src/deep', MAIN)
|
||||
|
||||
const stagedDir = dirAction('docs', t('unstageAll'))
|
||||
await act(async () => { stagedDir.click() })
|
||||
await flushEffects()
|
||||
expect(unstage).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'session' }), 'docs', MAIN)
|
||||
|
||||
// Neither click folded its row: the action is a sibling of the
|
||||
// disclosure button (and stops propagation anyway).
|
||||
expect(container.querySelector('button[data-dir="src/deep"]')!.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(container.querySelector('button[data-path="src/deep/a.ts"]')).not.toBeNull()
|
||||
expect(container.querySelector('button[data-dir="docs"]')!.getAttribute('aria-expanded')).toBe('true')
|
||||
} finally {
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves no icon for a status poll whose change list did not change', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fileIcons: string[] = []
|
||||
const folderIcons: string[] = []
|
||||
/** The icon resolvers are the re-render probe: they run inside the rows,
|
||||
* so one entry per call means one row actually re-rendered. */
|
||||
const service = {
|
||||
subscribe: () => () => {},
|
||||
fileIcon: (path: string) => { fileIcons.push(path); return null },
|
||||
folderIcon: (path: string, open: boolean) => { folderIcons.push(`${path}:${String(open)}`); return null },
|
||||
} as unknown as BetterSidebarService
|
||||
const ctx = { get: (name: string) => (name === 'betterSidebar' ? service : undefined) } as unknown as Context
|
||||
|
||||
const { container, root } = makeRoot()
|
||||
try {
|
||||
vi.spyOn(api, 'gitWorktrees').mockResolvedValue([{ path: MAIN, branch: 'main', current: true, changes: 2 }])
|
||||
// Every poll answers with a fresh object and a fresh entries array (the
|
||||
// store's real shape). The FIRST answer carries no repository list and
|
||||
// the later ones do — a field OUTSIDE the change list moves, so the store
|
||||
// cannot preserve the snapshot identity and hands the lens a new array
|
||||
// whose content is equal. Only the CONTENT key plus the memoized rows
|
||||
// hold there; identity alone would rebuild the tree and re-render rows.
|
||||
let poll = 0
|
||||
vi.spyOn(api, 'gitStatus').mockImplementation(async () => {
|
||||
poll += 1
|
||||
return {
|
||||
isRepo: true,
|
||||
branch: 'main',
|
||||
...(poll === 1 ? {} : { repositories: [MAIN] }),
|
||||
// One compressed directory row (folder icon) + its two file rows.
|
||||
entries: [{ path: 'src/deep/a.ts', xy: ' M' }, { path: 'src/deep/b.ts', xy: ' M' }],
|
||||
}
|
||||
})
|
||||
vi.spyOn(api, 'gitBranch').mockResolvedValue({ current: 'main', names: ['main'] })
|
||||
vi.spyOn(api, 'gitLog').mockResolvedValue([])
|
||||
|
||||
mountGit(root, { ctx })
|
||||
await flushEffects()
|
||||
expect(fileIcons).toEqual(['src/deep/a.ts', 'src/deep/b.ts'])
|
||||
expect(folderIcons).toEqual(['src/deep:true'])
|
||||
|
||||
fileIcons.length = 0
|
||||
folderIcons.length = 0
|
||||
// Tick 1: a NEW snapshot object with an equal-content change list.
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(2_600) })
|
||||
await flushEffects()
|
||||
expect(fileIcons).toEqual([])
|
||||
expect(folderIcons).toEqual([])
|
||||
|
||||
fileIcons.length = 0
|
||||
folderIcons.length = 0
|
||||
// Tick 2: a byte-identical answer (the store reuses the snapshot object).
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(2_600) })
|
||||
await flushEffects()
|
||||
expect(fileIcons).toEqual([])
|
||||
expect(folderIcons).toEqual([])
|
||||
// The probe measured stability, not loss: every row is still there.
|
||||
expect(container.querySelectorAll('[data-path]')).toHaveLength(2)
|
||||
expect(container.querySelectorAll('button[data-dir="src/deep"]')).toHaveLength(1)
|
||||
} finally {
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -63,10 +63,16 @@ describe('resolveOpenWithTargets', () => {
|
||||
expect(targets.map(target => target.id)).toEqual(['explorer', 'vscode', 'cursor', 'zed', 'custom:e1'])
|
||||
})
|
||||
|
||||
it('drops local-only targets in SSH mode (file manager, Zed, non-VSCode custom)', () => {
|
||||
it('drops the local-only URL openers in SSH mode but KEEPS reveal (file manager)', () => {
|
||||
const config = parseOpenWithConfig({ sshHost: 'dev', customEditors: [localCustom, familyCustom] })
|
||||
const targets = resolveOpenWithTargets(config)
|
||||
expect(targets.map(target => target.id)).toEqual(['vscode', 'cursor', 'custom:e2'])
|
||||
// Zed and the non-VSCode custom editor cannot reach a remote path. The
|
||||
// `explorer` target stays: reveal runs on the host machine (the one that
|
||||
// owns the workspace) and is the only reveal left when the DSH host's own
|
||||
// open-in-app capability is unavailable.
|
||||
expect(targets.map(target => target.id)).toEqual(['explorer', 'vscode', 'cursor', 'custom:e2'])
|
||||
expect(targets[0]?.kind).toBe('reveal')
|
||||
expect(targets.filter(target => target.kind === 'reveal')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('prunes unknown pinned ids only when resolving (the parse keeps them)', () => {
|
||||
|
||||
@@ -163,6 +163,45 @@ describe('useGitStatus', () => {
|
||||
await unmount()
|
||||
})
|
||||
|
||||
it('keeps the snapshot identity when an idle poll returns the same answer', async () => {
|
||||
// Without this, every 2.5s tick minted a new object and every downstream
|
||||
// memo (the changes tree, the file tree rows) re-ran on an unchanged view.
|
||||
// A FRESH object per call: the host really does re-serialize the answer
|
||||
// every poll, so `mockResolvedValue` (same reference) would not prove this.
|
||||
gitStatus.mockImplementation(async () => snapshot())
|
||||
// Collect the snapshots the hook PUBLISHES (the view object itself is
|
||||
// re-created per render, so identity must be read off the snapshot).
|
||||
const published: (GitStatusResult | null)[] = []
|
||||
let view: GitStatusView | undefined
|
||||
const { unmount } = await mount(createElement(Probe, {
|
||||
scope: { sessionId: 's-idle' },
|
||||
onView: (next) => { view = next; published.push(next.snapshot) },
|
||||
}))
|
||||
expect(gitStatus).toHaveBeenCalledTimes(1)
|
||||
const before = published[published.length - 1]
|
||||
expect(before).not.toBeNull()
|
||||
await act(async () => { view?.refresh(); await Promise.resolve(); await Promise.resolve() })
|
||||
expect(gitStatus).toHaveBeenCalledTimes(2)
|
||||
expect(published[published.length - 1]).toBe(before)
|
||||
await unmount()
|
||||
})
|
||||
|
||||
it('replaces the snapshot when the poll reports a real change', async () => {
|
||||
gitStatus.mockResolvedValueOnce(snapshot())
|
||||
const published: (GitStatusResult | null)[] = []
|
||||
let view: GitStatusView | undefined
|
||||
const { unmount } = await mount(createElement(Probe, {
|
||||
scope: { sessionId: 's-change' },
|
||||
onView: (next) => { view = next; published.push(next.snapshot) },
|
||||
}))
|
||||
const before = published[published.length - 1]
|
||||
gitStatus.mockResolvedValue(snapshot({ entries: [{ path: 'src/a.ts', xy: ' M' }, { path: 'src/b.ts', xy: '??' }] }))
|
||||
await act(async () => { view?.refresh(); await Promise.resolve(); await Promise.resolve() })
|
||||
expect(published[published.length - 1]).not.toBe(before)
|
||||
expect(view?.statusOf('/ws/src/b.ts')?.tone).toBe('untracked')
|
||||
await unmount()
|
||||
})
|
||||
|
||||
it('invalidateGitStatus re-reads every live key of one session', async () => {
|
||||
gitStatus.mockResolvedValue(snapshot())
|
||||
const { unmount } = await mount(createElement(Probe, { scope: { sessionId: 's6' }, onView: () => {} }))
|
||||
|
||||
+174
-5
@@ -14,12 +14,13 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { validateHeaderValue } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { inflateRawSync } from 'node:zlib'
|
||||
import { apply } from '../src/index.ts'
|
||||
import { archiveUrl } from '../src/client/api.ts'
|
||||
import { archiveNameOf, collectZipEntries } from '../src/archive-route.ts'
|
||||
import { archiveNameOf, collectZipEntries, contentDispositionOf, disambiguateArchiveNames } from '../src/archive-route.ts'
|
||||
import { crc32, buildZip, type ZipEntry } from '../src/zip.ts'
|
||||
import { SidebarError } from '../src/wire.ts'
|
||||
import type { SidebarWebRoute, SidebarWebUpgradeRoute } from '../src/context-types.ts'
|
||||
@@ -224,6 +225,52 @@ describe('buildZip', () => {
|
||||
.rejects.toMatchObject({ code: 'fs-error' })
|
||||
})
|
||||
|
||||
it('enforces the byte bound BEFORE reading the payload (stat first)', async () => {
|
||||
const root = tempRoot()
|
||||
const huge = join(root, 'huge.bin')
|
||||
writeFileSync(huge, Buffer.alloc(4 * 1024 * 1024, 7))
|
||||
// A source far past the budget is refused on its stat, with the archive's
|
||||
// own ceiling in the message (never an internal budget number)…
|
||||
await expect(buildZip([{ path: huge, name: 'huge.bin' }], { maxBytes: 1024 }))
|
||||
.rejects.toThrow(/exceeds the 1024 byte limit/)
|
||||
await expect(buildZip([{ path: huge, name: 'huge.bin' }], { maxBytes: 1024 }))
|
||||
.rejects.toMatchObject({ code: 'fs-error' })
|
||||
// maxBytes: 0 refuses even a one-byte source.
|
||||
const one = join(root, 'one.txt')
|
||||
writeFileSync(one, 'x')
|
||||
await expect(buildZip([{ path: one, name: 'one.txt' }], { maxBytes: 0 }))
|
||||
.rejects.toThrow(/exceeds the 0 byte limit/)
|
||||
// …and a source that exactly fills the budget is accepted.
|
||||
const two = join(root, 'two.txt')
|
||||
writeFileSync(two, 'xy')
|
||||
expect(readZip(await buildZip([{ path: two, name: 't.txt' }], { maxBytes: 2 }))[0]!.size).toBe(2)
|
||||
// The budget is the REMAINING allowance: the second entry no longer fits
|
||||
// (an exhausted budget refuses even a zero-byte source, so a FIFO can
|
||||
// never be opened — see the probe below).
|
||||
const empty = join(root, 'empty.txt')
|
||||
writeFileSync(empty, '')
|
||||
await expect(buildZip([{ path: two, name: 'a.txt' }, { path: empty, name: 'b.txt' }], { maxBytes: 2 }))
|
||||
.rejects.toMatchObject({ code: 'fs-error' })
|
||||
})
|
||||
|
||||
it('never opens a source the size probe already refused (FIFO)', async () => {
|
||||
if (process.platform === 'win32') return // no FIFOs there
|
||||
const root = tempRoot()
|
||||
const fifo = join(root, 'pipe')
|
||||
const made = spawnSync('mkfifo', [fifo], { encoding: 'utf8' })
|
||||
if (made.error !== undefined || made.status !== 0) return // binary absent: skip
|
||||
// A 2-byte file consumes the whole 2-byte budget, so the FIFO's remaining
|
||||
// allowance is 0. Reading an unwritten FIFO BLOCKS FOREVER, so this test
|
||||
// can only complete when the size probe refuses it before open() — with the
|
||||
// read-then-check order it hangs and vitest fails it on timeout instead.
|
||||
// (This is the discriminating guard for F3: a "rejects quickly" timing
|
||||
// probe does NOT discriminate, because the reverted code also rejects.)
|
||||
const two = join(root, 'two.txt')
|
||||
writeFileSync(two, 'xy')
|
||||
await expect(buildZip([{ path: two, name: 'a.txt' }, { path: fifo, name: 'p' }], { maxBytes: 2 }))
|
||||
.rejects.toThrow(/exceeds the 2 byte limit/)
|
||||
}, 5_000)
|
||||
|
||||
it('reports an unreadable source as fs-error and refuses traversal names', async () => {
|
||||
const root = tempRoot()
|
||||
await expect(buildZip([{ path: join(root, 'missing.txt'), name: 'missing.txt' }]))
|
||||
@@ -232,6 +279,22 @@ describe('buildZip', () => {
|
||||
.rejects.toMatchObject({ code: 'bad-request' })
|
||||
})
|
||||
|
||||
it('keeps a backslash as part of the file name (POSIX names are literal)', async () => {
|
||||
const root = tempRoot()
|
||||
// `a\b.txt` is ONE legal POSIX file name; splitting it into a directory
|
||||
// would silently rename the member (F8).
|
||||
const path = join(root, 'a\\b.txt')
|
||||
writeFileSync(path, 'literal\n')
|
||||
const archive = await buildZip([{ path, name: 'a\\b.txt' }])
|
||||
const entries = readZip(archive)
|
||||
expect(entries).toHaveLength(1)
|
||||
expect(entries[0]!.name).toBe('a\\b.txt')
|
||||
expect(entries[0]!.data.toString('utf8')).toBe('literal\n')
|
||||
// A traversal name is still refused, and so is a bare '..'.
|
||||
await expect(buildZip([{ path, name: 'dir/../../x' }])).rejects.toMatchObject({ code: 'bad-request' })
|
||||
await expect(buildZip([{ path, name: '..' }])).rejects.toMatchObject({ code: 'bad-request' })
|
||||
})
|
||||
|
||||
it('passes `unzip -t` when the binary exists (skipped otherwise)', async () => {
|
||||
const probe = spawnSync('unzip', ['-v'], { encoding: 'utf8' })
|
||||
if (probe.error !== undefined || probe.status !== 0) return
|
||||
@@ -307,6 +370,52 @@ describe('archiveNameOf', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('contentDispositionOf', () => {
|
||||
it('never puts a non-latin1 character in the header value', () => {
|
||||
const header = contentDispositionOf('报告.zip')
|
||||
// Node's writeHead rejects any header value above U+00FF — the whole
|
||||
// reason the raw name cannot be used (it 500s the route). Control: the
|
||||
// exact shape this fix replaced must FAIL the same check.
|
||||
expect(() => validateHeaderValue('content-disposition', 'attachment; filename="报告.zip"'))
|
||||
.toThrow(/Invalid character in header content/)
|
||||
expect([...header].every(char => char.codePointAt(0)! <= 0xFF)).toBe(true)
|
||||
expect(validateHeaderValue('content-disposition', header)).toBeUndefined()
|
||||
expect(header).toBe(`attachment; filename="download.zip"; filename*=UTF-8''${encodeURIComponent('报告.zip')}`)
|
||||
})
|
||||
|
||||
it('keeps a plain ASCII name in both halves (suffix stripped for the fallback stem only)', () => {
|
||||
const header = contentDispositionOf('docs.zip')
|
||||
expect(header).toBe(`attachment; filename="docs.zip"; filename*=UTF-8''docs.zip`)
|
||||
expect(validateHeaderValue('content-disposition', header)).toBeUndefined()
|
||||
// A mixed name keeps whatever ASCII survives in the fallback.
|
||||
expect(contentDispositionOf('報告 Q3.zip')).toContain('filename=" Q3.zip"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('disambiguateArchiveNames', () => {
|
||||
it('keeps the basename when nothing collides', () => {
|
||||
expect(disambiguateArchiveNames(['/ws/src', '/ws/notes.md'])).toEqual(['src', 'notes.md'])
|
||||
})
|
||||
|
||||
it('prepends parent directories only for the colliding selections', () => {
|
||||
expect(disambiguateArchiveNames(['/ws/a/index.ts', '/ws/b/index.ts']))
|
||||
.toEqual(['a/index.ts', 'b/index.ts'])
|
||||
// The unaffected selection keeps its short name.
|
||||
expect(disambiguateArchiveNames(['/ws/a/index.ts', '/ws/notes.md']))
|
||||
.toEqual(['index.ts', 'notes.md'])
|
||||
})
|
||||
|
||||
it('walks further up while ancestors still collide', () => {
|
||||
// Both are `src/index.ts` relative to their own parent: one more level.
|
||||
expect(disambiguateArchiveNames(['/ws/a/src/index.ts', '/ws/b/src/index.ts']))
|
||||
.toEqual(['a/src/index.ts', 'b/src/index.ts'])
|
||||
})
|
||||
|
||||
it('falls back to the full path when no ancestor can separate the names', () => {
|
||||
expect(disambiguateArchiveNames(['/ws/a.txt', '/ws/a.txt'])).toEqual(['ws/a.txt', 'ws/a.txt'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('/sidebar/archive route', () => {
|
||||
it('archiveUrl builds the GET URL the route parses (repeated path, encoded)', () => {
|
||||
const url = archiveUrl({ sessionId: 's-1', cwd: '/work tree' }, ['/work tree/a b.txt', '/work tree/子目录'], 'my zip.zip')
|
||||
@@ -335,7 +444,10 @@ describe('/sidebar/archive route', () => {
|
||||
const response = await get(route, url)
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers['content-type']).toBe('application/zip')
|
||||
expect(response.headers['content-disposition']).toBe('attachment; filename="我的打包.zip"')
|
||||
// F1: the name is never header-raw. See the real-HTTP case below for the
|
||||
// node:http proof that a non-latin1 name no longer 500s.
|
||||
expect(response.headers['content-disposition'])
|
||||
.toBe(`attachment; filename="download.zip"; filename*=UTF-8''${encodeURIComponent('我的打包.zip')}`)
|
||||
expect(Number(response.headers['content-length'])).toBe(response.body.byteLength)
|
||||
const entries = readZip(response.body)
|
||||
expect(entries.map(entry => entry.name)).toEqual([
|
||||
@@ -350,6 +462,63 @@ describe('/sidebar/archive route', () => {
|
||||
expect(byName.get('notes.md')!.data.toString('utf8')).toBe('# notes\n')
|
||||
})
|
||||
|
||||
it('serves a non-latin1 download name through REAL node:http (no 500, RFC 5987)', async () => {
|
||||
const root = tempRoot()
|
||||
const workspace = join(root, 'workspace')
|
||||
mkdirSync(join(workspace, '报告'), { recursive: true })
|
||||
writeFileSync(join(workspace, '报告', '数据.txt'), 'x\n')
|
||||
const route = mountArchive()
|
||||
const name = '报告.zip'
|
||||
const url = `/sidebar/archive?sessionId=s-zip&cwd=${encodeURIComponent(workspace)}`
|
||||
+ `&name=${encodeURIComponent(name)}`
|
||||
+ `&path=${encodeURIComponent(join(workspace, '报告'))}`
|
||||
// The route handler answers a REAL ServerResponse here: node:http's
|
||||
// writeHead validates header values, which the plain-object stub in the
|
||||
// other cases cannot do — that is exactly how the Chinese-name 500 hid.
|
||||
const { createServer } = await import('node:http')
|
||||
const server = createServer((req, res) => { void route.handler(req as never, res as never) })
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
try {
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('test setup: no server port')
|
||||
const response = await fetch(`http://127.0.0.1:${address.port}${url}`)
|
||||
expect(response.status).toBe(200)
|
||||
const disposition = response.headers.get('content-disposition') ?? ''
|
||||
// The ASCII half is what old clients read; the RFC 5987 half carries the
|
||||
// real name and is what every current browser picks.
|
||||
expect(disposition).toContain(`filename*=UTF-8''${encodeURIComponent(name)}`)
|
||||
expect(disposition).toContain('filename="download.zip"')
|
||||
// The header VALUE must be latin1-clean (Node's own rule).
|
||||
expect([...disposition].every(char => char.codePointAt(0)! <= 0xFF)).toBe(true)
|
||||
const entries = readZip(Buffer.from(await response.arrayBuffer()))
|
||||
expect(entries.map(entry => entry.name)).toEqual(['报告/', '报告/数据.txt'])
|
||||
} finally {
|
||||
await new Promise<void>(resolve => server.close(() => resolve()))
|
||||
}
|
||||
})
|
||||
|
||||
it('disambiguates same-name selections with their parent directories', async () => {
|
||||
const root = tempRoot()
|
||||
const workspace = join(root, 'workspace')
|
||||
mkdirSync(join(workspace, 'a'), { recursive: true })
|
||||
mkdirSync(join(workspace, 'b'), { recursive: true })
|
||||
writeFileSync(join(workspace, 'a', 'index.ts'), 'from a\n')
|
||||
writeFileSync(join(workspace, 'b', 'index.ts'), 'from b\n')
|
||||
const route = mountArchive()
|
||||
const url = `/sidebar/archive?sessionId=s-zip&cwd=${encodeURIComponent(workspace)}`
|
||||
+ `&path=${encodeURIComponent(join(workspace, 'a', 'index.ts'))}`
|
||||
+ `&path=${encodeURIComponent(join(workspace, 'b', 'index.ts'))}`
|
||||
const response = await get(route, url)
|
||||
expect(response.status).toBe(200)
|
||||
const entries = readZip(response.body)
|
||||
const names = entries.map(entry => entry.name)
|
||||
expect(new Set(names).size).toBe(names.length)
|
||||
expect(names).toEqual(['a/index.ts', 'b/index.ts'])
|
||||
const byName = new Map(entries.map(entry => [entry.name, entry]))
|
||||
expect(byName.get('a/index.ts')!.data.toString('utf8')).toBe('from a\n')
|
||||
expect(byName.get('b/index.ts')!.data.toString('utf8')).toBe('from b\n')
|
||||
})
|
||||
|
||||
it('never follows a symlink out of the workspace', async () => {
|
||||
const root = tempRoot()
|
||||
const workspace = join(root, 'workspace')
|
||||
@@ -415,11 +584,11 @@ describe('/sidebar/archive route', () => {
|
||||
const path = encodeURIComponent(join(workspace, 'a.txt'))
|
||||
const scope = `sessionId=s-zip&cwd=${encodeURIComponent(workspace)}&path=${path}`
|
||||
const traversal = await get(route, `/sidebar/archive?${scope}&name=${encodeURIComponent('../evil")b.zip')}`)
|
||||
expect(traversal.headers['content-disposition']).toBe('attachment; filename="evil)b.zip"')
|
||||
expect(traversal.headers['content-disposition']).toContain('filename="evil)b.zip"')
|
||||
const bare = await get(route, `/sidebar/archive?${scope}&name=docs`)
|
||||
expect(bare.headers['content-disposition']).toBe('attachment; filename="docs.zip"')
|
||||
expect(bare.headers['content-disposition']).toContain('filename="docs.zip"')
|
||||
const fallback = await get(route, `/sidebar/archive?${scope}`)
|
||||
expect(fallback.headers['content-disposition']).toBe('attachment; filename="archive.zip"')
|
||||
expect(fallback.headers['content-disposition']).toContain('filename="archive.zip"')
|
||||
})
|
||||
|
||||
it('rejects a non-GET method', async () => {
|
||||
|
||||
Reference in New Issue
Block a user