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:
Menghuan1918
2026-09-28 18:20:08 +08:00
parent b011be1e98
commit 13d4bae321
17 changed files with 846 additions and 127 deletions
+25
View File
@@ -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([])
+40
View File
@@ -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')
})
})
+30 -6
View File
@@ -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)',
])
})
+123 -5
View File
@@ -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()
}
})
})
+8 -2
View File
@@ -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)', () => {
+39
View File
@@ -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
View File
@@ -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 () => {