From 520867b6fa8eee90f7cd103a8d33ea3bd602bcb5 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:47:19 +0800 Subject: [PATCH] fix(dsh): preserve bookmark focus and canonicalize lock tests Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- plugins/dsh/src/client/catalog-page.tsx | 2 +- plugins/dsh/src/client/favorite-button.tsx | 4 +-- .../dsh/src/client/native-control-styles.tsx | 1 + plugins/dsh/test/favorites-store.test.ts | 35 +++++++++++++------ plugins/dsh/test/market-card.test.tsx | 7 ++++ 5 files changed, 36 insertions(+), 13 deletions(-) diff --git a/plugins/dsh/src/client/catalog-page.tsx b/plugins/dsh/src/client/catalog-page.tsx index 1d8c4fa8c..6756d79e3 100644 --- a/plugins/dsh/src/client/catalog-page.tsx +++ b/plugins/dsh/src/client/catalog-page.tsx @@ -177,7 +177,7 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa } function favoriteAction(item: FavoriteSkill) { const pressed = favorites.items.some(saved => favoriteIdentity(saved) === favoriteIdentity(item)); - return { if (pressed && favoriteOpening?.key === favoriteIdentity(item)) cancelFavoriteOpen(); void favorites.save(item, !pressed).then(saved => { if (saved && favoriteOnly && pressed) favoriteFilter.current?.focus({ preventScroll: true }); }); diff --git a/plugins/dsh/src/client/favorite-button.tsx b/plugins/dsh/src/client/favorite-button.tsx index a0e276027..8b770c4f8 100644 --- a/plugins/dsh/src/client/favorite-button.tsx +++ b/plugins/dsh/src/client/favorite-button.tsx @@ -7,6 +7,6 @@ export function FavoriteButton({ item, pressed, disabled, busy, onToggle }: { }) { const label = `${pressed ? '取消收藏' : '收藏'} ${item.displayName || item.slug} @${item.namespace}/${item.slug}`; return ; + aria-label={label} aria-pressed={pressed} aria-busy={busy || undefined} aria-disabled={busy || undefined} title={pressed ? '取消本机收藏' : '收藏到本机'} disabled={disabled} + onClick={event => { event.stopPropagation(); if (!disabled && !busy) onToggle(); }}>; } diff --git a/plugins/dsh/src/client/native-control-styles.tsx b/plugins/dsh/src/client/native-control-styles.tsx index 55e111583..b664226b7 100644 --- a/plugins/dsh/src/client/native-control-styles.tsx +++ b/plugins/dsh/src/client/native-control-styles.tsx @@ -181,6 +181,7 @@ export function NativeControlStyles() { .skillhub-controls .astron-sh-location{display:flex;align-items:center;gap:12px;font-size:13px}.skillhub-controls .astron-sh-location strong{font-size:15px;font-weight:600}.skillhub-controls .astron-sh-location>span{color:var(--astron-sh-muted)}.skillhub-controls .astron-sh-location>svg{color:var(--astron-sh-accent)} .skillhub-controls .astron-sh-toolbar-actions{display:flex;gap:6px}.skillhub-controls .astron-sh-icon-button{height:32px;min-height:32px;width:32px;padding:6px}.skillhub-controls .astron-sh-source-info{padding:10px 14px;border-radius:7px;background:var(--astron-sh-soft);font-size:12px;color:var(--astron-sh-muted);overflow-wrap:anywhere} .skillhub-controls .astron-sh-skill-heading>div{flex:1}.skillhub-controls .astron-sh-favorite-button{flex-shrink:0;color:var(--astron-sh-muted)}.skillhub-controls .astron-sh-skill-heading>.astron-sh-favorite-button{position:relative;z-index:2;align-self:flex-start;margin-left:auto} +.skillhub-controls .astron-sh-favorite-button[aria-disabled=true]{opacity:.5;cursor:wait} .skillhub-controls .astron-sh-favorite-button[aria-pressed=true]{color:var(--astron-sh-accent);background:var(--astron-sh-soft)}.skillhub-controls .astron-sh-favorite-button[aria-pressed=true] svg{fill:currentColor}.skillhub-controls .astron-sh-favorite-filter{display:flex;align-items:center;gap:6px;flex-shrink:0}.skillhub-controls .astron-sh-favorite-filter[aria-pressed=true]{color:var(--astron-sh-accent);border-color:var(--astron-sh-accent);background:var(--astron-sh-soft)}.skillhub-controls .astron-sh-favorite-filter svg{width:14px;height:14px}.skillhub-controls .astron-sh-result-summary{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.skillhub-controls .astron-sh-favorite-feedback{margin:0 0 14px;display:flex;align-items:center;gap:10px;flex-wrap:wrap}.skillhub-controls .astron-sh-favorite-feedback p{margin:0}.skillhub-controls .astron-sh-detail-actions{display:flex;align-items:center;gap:8px;margin-left:auto} .skillhub-controls .astron-sh-skill-card{height:auto;min-height:210px}.skillhub-controls .astron-sh-avatar{width:38px;height:38px;border-radius:9px}.skillhub-controls .astron-sh-avatar>span{font-size:19px}.skillhub-controls .astron-sh-skill-heading{padding:15px 16px 10px;gap:11px}.skillhub-controls .astron-sh-skill-heading h3{font-size:15px;line-height:20px;font-weight:600}.skillhub-controls .astron-sh-skill-heading p{font-size:12px;line-height:18px;margin-top:3px}.skillhub-controls .astron-sh-skill-body{padding:0 16px 10px}.skillhub-controls .astron-sh-skill-summary{font-size:13px;line-height:20px;min-height:40px;max-height:40px}.skillhub-controls .astron-sh-skill-meta{margin-top:8px;font-size:12px;line-height:18px}.skillhub-controls .astron-sh-skill-card>.astron-sh-card-footer{padding:7px 15px;min-height:43px;height:auto}.skillhub-controls .astron-sh-metrics{font-size:12px;gap:11px}.skillhub-controls .astron-sh-metrics svg{width:12px;height:12px;color:color-mix(in srgb,var(--astron-sh-accent) 60%,var(--astron-sh-muted))}.skillhub-controls .astron-sh-card-open{color:var(--astron-sh-accent);font-size:12px}.skillhub-controls .astron-sh-detail-heading .astron-sh-avatar{width:46px;height:46px} .skillhub-controls .astron-sh-select-root{display:inline-flex;min-width:0}.skillhub-controls .astron-sh-select-trigger{width:100%;min-width:120px;display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:13px;height:38px;border:1px solid var(--astron-sh-border);border-radius:7px;background:var(--astron-sh-bg);padding:8px 11px;text-align:start}.skillhub-controls .astron-sh-select-trigger>svg{width:14px;height:14px;color:var(--astron-sh-muted)} diff --git a/plugins/dsh/test/favorites-store.test.ts b/plugins/dsh/test/favorites-store.test.ts index e9e7a9c2d..41803583d 100644 --- a/plugins/dsh/test/favorites-store.test.ts +++ b/plugins/dsh/test/favorites-store.test.ts @@ -122,7 +122,7 @@ describe('local favorites preferences', () => { await expect(blocked.set(source, item, true)).rejects.toMatchObject({ reason: 'UNAVAILABLE', message: 'UNAVAILABLE' }); })); - test('two Node writers contend behind an explicit lock barrier without losing either record', () => fixture(async (root, store, file) => { + test.each(['direct', 'home-alias'] as const)('two Node writers contend behind an explicit lock barrier without losing either record (%s)', variant => fixture(async (root, store, file) => { await store.set(source, item, true); const build = await Bun.build({ entrypoints: [fileURLToPath(new URL('../src/host/favorites-store.ts', import.meta.url))], target: 'node', format: 'esm', packages: 'external' }); if (!build.success) throw new AggregateError(build.logs, 'Store test build failed'); @@ -132,7 +132,8 @@ describe('local favorites preferences', () => { await writeFile(runner, `import fs from 'node:fs/promises'; import {syncBuiltinESMExports} from 'node:module'; import {join} from 'node:path'; -const lock=join(process.argv[2],'skillhub/favorites.json.lock'); +const canonicalHome=await fs.realpath(process.argv[2]); +const lock=join(canonicalHome,'skillhub/favorites.json.lock'); const originalWrite=fs.writeFile; let observed=false; // Observe the SDK's real exclusive-create failure, never replace lock behavior. @@ -141,37 +142,51 @@ fs.writeFile=async function(filename,data,options){ catch(error){ if(!observed&&String(filename)===lock&&options?.flag==='wx'){ const contention=error.code==='EEXIST'||(error.code==='EPERM'&&await fs.lstat(lock).then(value=>value.isFile(),()=>false)); - if(contention){observed=true;process.send({type:'contended',code:error.code});} + if(contention){observed=true;process.send({type:'contended',code:error.code,canonicalized:canonicalHome!==process.argv[2]});} } throw error; } }; syncBuiltinESMExports(); const {FavoritesStore}=await import('./store.mjs'); -try {const value=await new FavoritesStore(process.argv[2]).set(process.argv[3],{namespace:'team',slug:process.argv[4]},true);process.send({type:'saved',count:value.items.length});process.disconnect();} catch(error){process.send({type:'failed',reason:error.reason??error.name});process.disconnect();process.exitCode=1;}`); - const children: ReturnType[] = [], counts: number[] = [], completions: Promise[] = [], ready: Promise[] = []; +try {const value=await new FavoritesStore(process.argv[2]).set(process.argv[3],{namespace:'team',slug:process.argv[4]},true);process.send({type:'saved',count:value.items.length});process.disconnect();} catch(error){process.send({type:'failed',reason:error.reason??error.name,observed,canonicalized:canonicalHome!==process.argv[2]});process.disconnect();process.exitCode=1;}`); + const childHome = variant === 'home-alias' ? join(root, 'home-alias') : root; + if (variant === 'home-alias') await symlink(root, childHome, process.platform === 'win32' ? 'junction' : 'dir'); + const children: ReturnType[] = [], counts: number[] = [], canonicalized: boolean[] = [], completions: Promise[] = [], ready: Promise[] = []; try { await withFileLock(file, async () => { for (const slug of ['first', 'second']) { - const child = spawn('node', [runner, root, source, slug], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] }); children.push(child); + const child = spawn('node', [runner, childHome, source, slug], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] }); children.push(child); + let stderr = '', reportedFailure = ''; + child.stderr?.setEncoding('utf8'); + child.stderr?.on('data', chunk => { stderr = (stderr + chunk).slice(-4096); }); + child.on('message', message => { + if (message && typeof message === 'object' && 'type' in message && message.type === 'failed') reportedFailure = JSON.stringify(message); + }); + const failure = (note: string) => new Error([note, reportedFailure, stderr.trim()].filter(Boolean).join('\n')); ready.push(new Promise((resolve, reject) => { let contended = false; - const deadline = setTimeout(() => reject(new Error('Writer did not attempt the held lock')), 5000); + const deadline = setTimeout(() => reject(failure('Writer did not attempt the held lock')), 5000); child.on('message', message => { if (message && typeof message === 'object' && 'type' in message && message.type === 'contended' - && 'code' in message && (message.code === 'EEXIST' || message.code === 'EPERM')) { contended = true; clearTimeout(deadline); resolve(); } + && 'code' in message && (message.code === 'EEXIST' || message.code === 'EPERM')) { + contended = true; + canonicalized.push('canonicalized' in message && message.canonicalized === true); + clearTimeout(deadline); resolve(); + } }); child.once('error', error => { clearTimeout(deadline); reject(error); }); - child.once('exit', code => { clearTimeout(deadline); if (!contended) reject(new Error(`Writer exited ${code} before attempting the held lock`)); }); + child.once('close', code => { clearTimeout(deadline); if (!contended) reject(failure(`Writer exited ${code} before attempting the held lock`)); }); })); const completion = new Promise((resolve, reject) => { child.on('message', message => { if (message && typeof message === 'object' && 'type' in message && message.type === 'saved' && 'count' in message && typeof message.count === 'number') counts.push(message.count); }); - child.once('error', reject); child.once('exit', code => code === 0 ? resolve() : reject(new Error(`Writer exited ${code}`))); + child.once('error', reject); child.once('close', code => code === 0 ? resolve() : reject(failure(`Writer exited ${code}`))); }); void completion.catch(() => {}); completions.push(completion); } await Promise.all(ready); + if (variant === 'home-alias') expect(canonicalized).toEqual([true, true]); expect(JSON.parse(await readFile(file, 'utf8')).items.map((entry: { slug: string }) => entry.slug)).toEqual(['writer']); }); await Promise.all(completions); diff --git a/plugins/dsh/test/market-card.test.tsx b/plugins/dsh/test/market-card.test.tsx index 979d470bc..bae66ebb5 100644 --- a/plugins/dsh/test/market-card.test.tsx +++ b/plugins/dsh/test/market-card.test.tsx @@ -36,6 +36,13 @@ describe('market presentation and action boundaries', () => { expect(html).toContain('aria-label="查看 文档助手 @demo/document 详情"'); expect(html).not.toContain('vundefined'); expect(html).not.toContain('收藏数'); }); + test('saving favorites remains keyboard focusable while unavailable favorites use native disabled', () => { + const saving = renderToStaticMarkup( {}} />); + expect(saving).toContain('aria-disabled="true"'); expect(saving).toContain('aria-busy="true"'); + expect(saving).not.toMatch(/]*\sdisabled=/); + const unavailable = renderToStaticMarkup( {}} />); + expect(unavailable).toMatch(/]*\sdisabled=""/); + }); test('hides only complete empty source records, preserving unsafe status', () => { const base = { checkedAt: '2026-10-20T00:00:00Z', complete: true, unavailable: [], records: [] }; expect(renderToStaticMarkup()).toBe('');