fix(test): 测试间状态串台与 macOS 临时目录路径导致的用例失败

两处既有测试基建问题(在慢 runner 的 CI 上表现为 free-window 用例
间歇性失败,阻塞 PR 合并):

- free-window/sidebar-crash/bottom-auto-terminal 三个 spec 共用 session
  id 's1':store 会按 200ms 防抖把每会话状态持久化到
  localStorage['dsh-sidebar:v1:s1'],前一个用例的迟写(drop 点 400,300
  → 浮窗 left=205)被下一用例 setSession 恢复,新浮窗断言(期望 317)
  被 querySelector 拿到的旧窗口炸掉——用例间隔是否超过 200ms 决定成
  败,本地快时常过、CI 慢必挂。修复:挂载 helper 缺省生成唯一
  session id(持久化往返用例显式传固定 id),afterEach 在 unstub 之后
  清 localStorage 兜底
- git/git-worktree 两个用例在 macOS 上必挂:tmpdir() 返回 /var 符号
  链接而 git 报告解析后的 /private/var 前缀,路径比较与
  resolveWorktree 的恒等匹配失配(CI 的 Linux 路径规范所以一直绿)。
  修复:期望值与传入 git API 的路径统一 realpathSync 规范化

本地全量 vitest 963 通过 0 失败(此前 free-window 间歇 0~2 失败 +
git 2 个环境性失败);free-window 单文件连跑 8 次稳定。
This commit is contained in:
Menghuan1918
2026-08-24 14:18:18 +08:00
parent 8e0ef7a175
commit 2b757cfe24
5 changed files with 60 additions and 21 deletions
+13 -3
View File
@@ -44,6 +44,9 @@ interface MountedSidebar {
}
/** Mount the real Sidebar shell against a minimal context (real store + service). */
/** Unique per-test session ids (see the comment inside). */
let sessionSeq = 0
function mountSidebar(): MountedSidebar {
vi.stubGlobal('WebSocket', FakeWebSocket)
const container = document.createElement('div')
@@ -52,14 +55,18 @@ function mountSidebar(): MountedSidebar {
const service = createBetterSidebarService(store)
// Fresh-session seed: the right panel starts OPEN, the bottom panel closed
// (bottomOpen false → the first expansion is a false→true TRANSITION).
store.setSession('s1')
// Unique session per test — the store persists per-session state to
// localStorage (200ms debounce); a shared id lets a previous test's late
// write leak into this store's setSession restore.
const sessionId = `s1-${++sessionSeq}`
store.setSession(sessionId)
// useSyncExternalStore requires STABLE snapshots across calls (the real DSH
// services return stable objects) — a fresh object per call loops forever.
const localeSnapshot = { active: 'en' }
const sessionsSnapshot = {
current: 's1',
current: sessionId,
// cwd present → api.sessionCwd is never called in these tests.
byId: { s1: { cwd: '/tmp' } },
byId: { [sessionId]: { cwd: '/tmp' } },
}
const ctx = {
locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot },
@@ -81,6 +88,9 @@ function mountSidebar(): MountedSidebar {
afterEach(() => {
document.body.innerHTML = ''
// Belt and braces: drop any persisted layout a pending 200ms debounce
// write left behind between tests (unique session ids already isolate).
localStorage.clear()
vi.unstubAllGlobals()
})
+18 -5
View File
@@ -60,7 +60,10 @@ interface Mounted {
unmount: () => void
}
function mountSidebar(): Mounted {
/** Unique per-test session ids (see the comment inside). */
let sessionSeq = 0
function mountSidebar(sessionId: string = `s1-${++sessionSeq}`): Mounted {
vi.stubGlobal('WebSocket', FakeWebSocket)
if (!('ResizeObserver' in globalThis)) {
vi.stubGlobal('ResizeObserver', class {
@@ -83,9 +86,15 @@ function mountSidebar(): Mounted {
component: ({ visible }) => createElement('div', { 'data-visible': String(visible) }, 'notes body'),
})
store.setPrefs({ ...store.getPrefs(), openByDefault: true })
store.setSession('s1')
// The default session id is unique per test: the store persists
// per-session state to localStorage on a 200ms debounce, and a shared id
// let a PREVIOUS test's late timer write leak into this store's
// setSession restore (the portaled-menu/resize flakes — a stale float at
// the old drop point won the querySelector race on slow runners). The
// persistence round-trip test passes its own fixed id instead.
store.setSession(sessionId)
const localeSnapshot = { active: 'en' }
const sessionsSnapshot = { current: 's1', byId: { s1: { cwd: '/tmp' } } }
const sessionsSnapshot = { current: sessionId, byId: { [sessionId]: { cwd: '/tmp' } } }
const ctx = {
locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot },
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } },
@@ -132,6 +141,10 @@ afterEach(() => {
document.body.innerHTML = ''
document.documentElement.style.cssText = ''
vi.unstubAllGlobals()
// Belt and braces (after unstub, so a test's storage stub never sees it):
// drop any persisted layout a pending 200ms debounce write left behind
// between tests — unique session ids already isolate the stores.
localStorage.clear()
})
describe('free windows: drag-out detection', () => {
@@ -380,7 +393,7 @@ describe('free windows: the window', () => {
const getItem = vi.fn((key: string): string | null => saved.get(key) ?? null)
const setItem = vi.fn((key: string, value: string): void => { saved.set(key, value) })
vi.stubGlobal('localStorage', { getItem, setItem, removeItem: () => {} })
const first = mountSidebar()
const first = mountSidebar('s1')
try {
act(() => { first.service.openTab({ type: 'notes', title: 'Notes' }) })
act(() => { first.store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) })
@@ -390,7 +403,7 @@ describe('free windows: the window', () => {
} finally {
first.unmount()
}
const second = mountSidebar()
const second = mountSidebar('s1')
try {
const state = second.store.getSnapshot().state!
expect(state.floats).toHaveLength(1)
+8 -5
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { spawnSync } from 'node:child_process'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { parseWorktreeList, resolveWorktree, status, worktrees } from '../src/git.ts'
@@ -67,13 +67,16 @@ describe('linked Git worktrees', () => {
git(main, ['commit', '-q', '-m', 'base'])
git(main, ['worktree', 'add', '-q', '-b', 'agent', agent])
writeFileSync(join(agent, 'tracked.txt'), 'changed by agent\n')
// macOS tmpdir() keeps the /var symlink while git reports the resolved
// /private/var prefix — canonicalize every path handed to git APIs.
const agentPath = realpathSync(agent)
const listed = await worktrees(main)
expect(listed).toHaveLength(2)
expect(listed.find(entry => entry.current)).toMatchObject({ branch: 'main', changes: 0 })
expect(listed.find(entry => !entry.current)).toMatchObject({ branch: 'agent', changes: 1 })
expect(resolve(await resolveWorktree(main, agent))).toBe(resolve(agent))
expect((await status(await resolveWorktree(main, agent))).entries).toEqual([
expect(resolve(await resolveWorktree(main, agentPath))).toBe(resolve(agentPath))
expect((await status(await resolveWorktree(main, agentPath))).entries).toEqual([
{ path: 'tracked.txt', xy: ' M' },
])
await expect(resolveWorktree(main, root)).rejects.toThrow('unknown linked worktree')
@@ -84,9 +87,9 @@ describe('linked Git worktrees', () => {
rmSync(agent, { recursive: true, force: true })
const remaining = await worktrees(main)
expect(remaining).toHaveLength(1)
expect(resolve(remaining[0]!.path)).toBe(resolve(main))
expect(resolve(remaining[0]!.path)).toBe(resolve(realpathSync(main)))
expect(remaining[0]!.current).toBe(true)
await expect(resolveWorktree(main, agent)).rejects.toThrow('unknown linked worktree')
await expect(resolveWorktree(main, agentPath)).rejects.toThrow('unknown linked worktree')
} finally {
try { git(main, ['worktree', 'remove', '--force', agent]) } catch { /* fixture may not be fully initialized */ }
rmSync(root, { recursive: true, force: true })
+8 -5
View File
@@ -1,5 +1,5 @@
import { execFile, execFileSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -10,6 +10,9 @@ import { parseLogLines, parsePorcelainZ, repoRoots, status } from '../src/git.ts
const execFileAsync = promisify(execFile)
const normalizePath = (path: string): string => path.replaceAll('\\', '/')
// macOS tmpdir() is the /var symlink while git reports the resolved
// /private/var prefix — canonicalize both sides before comparing.
const canonical = (path: string): string => normalizePath(realpathSync(path))
describe('git parsing', () => {
it('discovers and selects direct child repositories under a workspace directory', async () => {
@@ -23,11 +26,11 @@ describe('git parsing', () => {
execFileAsync('git', ['-C', second, 'init']),
])
await expect(repoRoots(workspace)).resolves.toEqual([normalizePath(first), normalizePath(second)])
await expect(status(workspace, normalizePath(second))).resolves.toMatchObject({
await expect(repoRoots(workspace)).resolves.toEqual([canonical(first), canonical(second)])
await expect(status(workspace, canonical(second))).resolves.toMatchObject({
isRepo: true,
root: normalizePath(second),
repositories: [normalizePath(first), normalizePath(second)],
root: canonical(second),
repositories: [canonical(first), canonical(second)],
})
} finally {
await rm(workspace, { recursive: true, force: true })
+13 -3
View File
@@ -47,6 +47,9 @@ interface MountedSidebar {
}
/** Mount the real Sidebar shell against a minimal context (real store + service). */
/** Unique per-test session ids (see the comment inside). */
let sessionSeq = 0
function mountSidebar(): MountedSidebar {
vi.stubGlobal('WebSocket', FakeWebSocket)
const container = document.createElement('div')
@@ -55,14 +58,18 @@ function mountSidebar(): MountedSidebar {
const service = createBetterSidebarService(store)
// Fresh-session seed: open the panel explicitly (openByDefault defaults off).
store.setPrefs({ ...store.getPrefs(), openByDefault: true })
store.setSession('s1')
// Unique session per test — the store persists per-session state to
// localStorage (200ms debounce); a shared id lets a previous test's late
// write leak into this store's setSession restore.
const sessionId = `s1-${++sessionSeq}`
store.setSession(sessionId)
// useSyncExternalStore requires STABLE snapshots across calls (the real DSH
// services return stable objects) — a fresh object per call loops forever.
const localeSnapshot = { active: 'en' }
const sessionsSnapshot = {
current: 's1',
current: sessionId,
// cwd present → api.sessionCwd is never called in these tests.
byId: { s1: { cwd: '/tmp' } },
byId: { [sessionId]: { cwd: '/tmp' } },
}
const ctx = {
locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot },
@@ -84,6 +91,9 @@ function mountSidebar(): MountedSidebar {
afterEach(() => {
document.body.innerHTML = ''
// Belt and braces: drop any persisted layout a pending 200ms debounce
// write left behind between tests (unique session ids already isolate).
localStorage.clear()
vi.unstubAllGlobals()
})