diff --git a/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md b/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md new file mode 100644 index 0000000000..e394b2cdb6 --- /dev/null +++ b/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md @@ -0,0 +1,20 @@ +- dialog "选择工作区目录": + - heading "选择工作区目录" [level=2] + - button "主目录" + - img + - button "browse-golden" + - button "编辑路径" + - list: + - listitem: + - img + - text: alpha + - img + - listitem: + - img + - text: beta + - img + - button "新建文件夹": + - img + - text: 新建文件夹 + - button "取消" + - button "打开" diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index e6efbd75ea..8d3316ab0a 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -197,8 +197,11 @@ it('adopts a directory through the composed in-app browse flow and lands in its // The browse occupant renders the Select Workspace Directory dialog at the // fixture home; select Documents, advance into project, and adopt it. const dialog = await screen.findByRole('dialog', { name: '选择工作区目录' }, { timeout: 10_000 }) - fireEvent.click(await within(dialog).findByRole('listitem', { name: /Documents/ }, { timeout: 10_000 })) - fireEvent.click(await within(dialog).findByRole('listitem', { name: /^project/ }, { timeout: 10_000 })) + // Row targeting goes through the visible label text: listitem accessible-name + // computation differs across dom-accessibility-api environments, while the + // row's name span is stable (clicks bubble to the row button). + fireEvent.click(await within(dialog).findByText('Documents', {}, { timeout: 10_000 })) + fireEvent.click(await within(dialog).findByText('project', {}, { timeout: 10_000 })) fireEvent.click(within(dialog).getByRole('button', { name: '打开' })) await findHeroComposer() await waitFor(() => { diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 674969debe..a034140bf2 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -13,8 +13,8 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { SessionId } from '@deepseek-ai/dsh-session' import { - acknowledgeReloadConnectionLoss, assertFixtureInventory, launchWebScaffold, seedSession, watchConsole, - webSnapshotMode, type WebScaffold, + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { saveFailureShot } from './support.ts' @@ -23,6 +23,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/workspace-management', i // spec needs any one cold session row, not new recorded content. const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) const MODE = webSnapshotMode() +const BROWSER_EXPECTED = join(SNAPSHOT_DIR, 'directory-browser.expected.md') const SEED_ID = 'workspace-management-web-e2e' describe('web e2e: workspace management (create / rename / flat view / hover card)', () => { @@ -345,6 +346,35 @@ describe('web e2e: workspace management (create / rename / flat view / hover car expect(tripwire.pageErrors).toEqual([]) }, 90_000) + it('matches the directory-browser dialog aria golden at a staged directory', async () => { + // A staged subtree under the scaffold cwd keeps the listing deterministic + // (normalizeAria scrubs the cwd), and pointing the in-process host's HOME + // at the cwd collapses the breadcrumb ancestry into the Home crumb — no + // machine-specific path segments or real $HOME contents enter the golden. + const staged = join(scaffold.workspaceCwd, 'browse-golden') + await mkdir(join(staged, 'alpha'), { recursive: true }) + await mkdir(join(staged, 'beta'), { recursive: true }) + const realHome = process.env.HOME + process.env.HOME = scaffold.workspaceCwd + try { + await page.getByRole('button', { name: 'Create workspace' }).click() + await page.getByRole('menuitem', { name: 'Open local folder…' }).click() + const dialog = page.getByRole('dialog', { name: '选择工作区目录' }) + await dialog.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: '编辑路径' }).click() + await dialog.getByLabel('编辑路径').fill(staged) + await dialog.getByLabel('编辑路径').press('Enter') + await expect.poll(() => dialog.getByText('alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(BROWSER_EXPECTED, snapshot, MODE) + await dialog.getByRole('button', { name: '取消' }).click() + await dialog.waitFor({ state: 'hidden', timeout: 10_000 }) + } finally { + process.env.HOME = realHome + } + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + it('shows the session hover card after a dwell on the row', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover')) // Expand Ungrouped to reveal the seeded session row, then dwell on it @@ -376,8 +406,8 @@ describe('web e2e: workspace management (create / rename / flat view / hover car it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => { expect(tripwire.warnings).toEqual([]) - // This spec mints no fixture directory contents of its own; the seed it - // reuses is owned (and inventory-guarded) by seeded-history. - await assertFixtureInventory(SNAPSHOT_DIR, ['.gitkeep']) + // The directory-browser aria golden is this spec's one owned artifact; + // the seed it reuses is owned (and inventory-guarded) by seeded-history. + await assertFixtureInventory(SNAPSHOT_DIR, ['.gitkeep', 'directory-browser.expected.md']) }) }) diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index f59a74aa7e..ad2107f54f 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -39,6 +39,18 @@ min-height: 20px; } +/* Deep chains scroll inside the trail (the effect pins the tail into view) + * so the edit zone to the right never leaves the bar. */ +.crumbTrail { + display: flex; + align-items: center; + gap: 4px; + flex: 0 1 auto; + min-width: 0; + overflow-x: auto; + scrollbar-width: none; +} + .crumbSeat { display: inline-flex; align-items: center; @@ -74,7 +86,7 @@ /* The empty remainder of the bar: invisible, but a real click target that * flips the bar into path-edit mode. */ .crumbEditZone { - flex: 1 1 0; + flex: 1 0 34px; min-width: 34px; align-self: stretch; border: none; diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 49cbff9fb3..a3bbc576a7 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -112,6 +112,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const [creatingFolder, setCreatingFolder] = useState(false) const [createError, setCreateError] = useState(null) const requestSeq = useRef(0) + // Deep ancestry overflows the trail; keep its tail (the current directory + // and the edit zone beside it) in view whenever the chain changes. + const crumbTrailRef = useRef(null) /** Replace the whole view with one freshly listed level (no selection). */ const navigate = useCallback((path?: string) => { @@ -213,16 +216,24 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, } // After the hooks: a closed dialog renders nothing and evaluates no copy. - if (!open) return null - const crumbSource = child ?? parent const crumbs = crumbSource === null ? [] : displayCrumbs(crumbSource, t('browser.home')) + const crumbTail = crumbs.at(-1)?.path + useEffect(() => { + const trail = crumbTrailRef.current + if (trail !== null) trail.scrollLeft = trail.scrollWidth + }, [crumbTail]) + + if (!open) return null const twoPane = selected !== null return ( { if (folderDraft === null) onClose() }} title={t('browser.title')} className={clsx(css.dialog)} headless @@ -233,19 +244,21 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, {pathDraft === null ? ( <> - {crumbs.map((crumb, index) => ( - - {index > 0 && } - - - ))} + + {crumbs.map((crumb, index) => ( + + {index > 0 && } + + + ))} + {/* The empty zone right of the crumbs is the path-edit affordance. */}