fix(dsh): preserve bookmark focus and canonicalize lock tests

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-11-04 12:47:19 +08:00
parent 1b3a336bca
commit 520867b6fa
5 changed files with 36 additions and 13 deletions

View file

@ -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 <FavoriteButton item={item} pressed={pressed} disabled={!favorites.ready || favorites.saving} busy={favorites.loading || favorites.saving}
return <FavoriteButton item={item} pressed={pressed} disabled={!favorites.ready} busy={favorites.loading || favorites.saving}
onToggle={() => {
if (pressed && favoriteOpening?.key === favoriteIdentity(item)) cancelFavoriteOpen();
void favorites.save(item, !pressed).then(saved => { if (saved && favoriteOnly && pressed) favoriteFilter.current?.focus({ preventScroll: true }); });

View file

@ -7,6 +7,6 @@ export function FavoriteButton({ item, pressed, disabled, busy, onToggle }: {
}) {
const label = `${pressed ? '取消收藏' : '收藏'} ${item.displayName || item.slug} @${item.namespace}/${item.slug}`;
return <Button variant="ghost" size="icon" className="astron-sh-icon-button astron-sh-favorite-button"
aria-label={label} aria-pressed={pressed} aria-busy={busy || undefined} title={pressed ? '取消本机收藏' : '收藏到本机'} disabled={disabled}
onClick={event => { event.stopPropagation(); onToggle(); }}><Icon name="bookmark" /></Button>;
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(); }}><Icon name="bookmark" /></Button>;
}

View file

@ -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)}

View file

@ -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<typeof spawn>[] = [], counts: number[] = [], completions: Promise<void>[] = [], ready: Promise<void>[] = [];
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<typeof spawn>[] = [], counts: number[] = [], canonicalized: boolean[] = [], completions: Promise<void>[] = [], ready: Promise<void>[] = [];
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<void>((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<void>((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);

View file

@ -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(<FavoriteButton item={item} pressed disabled={false} busy onToggle={() => {}} />);
expect(saving).toContain('aria-disabled="true"'); expect(saving).toContain('aria-busy="true"');
expect(saving).not.toMatch(/<button[^>]*\sdisabled=/);
const unavailable = renderToStaticMarkup(<FavoriteButton item={item} pressed={false} disabled busy onToggle={() => {}} />);
expect(unavailable).toMatch(/<button[^>]*\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(<InstallationStatus hideEmpty installation={base} />)).toBe('');