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:
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user