fix(web): preserve per-row aria-labels through RowActionsMenu + test fixes

CI vitest run on 3f8a2c5 surfaced 17 failures across 5 test files; this
commit addresses 15 that are this batch's responsibility.

1. RowActionsMenu now accepts ariaLabel on RowAction. Defaults to label.
   Admin pages (requests/quarantine/users) pass per-row aria-labels
   matching the pre-batch buttons ("Approve Geogaddi", "Resolve Roygbiv",
   "Make alice admin", etc.) so screen readers + tests find them.

2. PlayerBar.test.ts — anchored regex /^(play|pause)$/i so the new
   "Player options" overflow ⋮ doesn't also match /play|pause/i.

3. MobileNavDrawer.test.ts — added vi.mock for $app/state, $app/navigation,
   and $lib/auth/store.svelte (mirrors Shell.test.ts pattern). Without
   these, SvelteKit's notifiable_store helper isn't bootstrapped in
   vitest and the suite fails to load.

The 2 remaining vitest failures are in /admin/integrations Save flow
(putLidarrConfig spy not called). Untouched by this batch and the
recent "Save runs Test first" refactor (bca8622) appears related —
flagging for operator verification, not chasing as a regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 21:16:48 -04:00
parent 3f8a2c511a
commit 3ffd9beca0
6 changed files with 72 additions and 8 deletions
+19 -1
View File
@@ -1,5 +1,23 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
// SvelteKit's $app/state and $app/navigation can't be imported in vitest
// without bootstrapping the runtime; stub them. Mirrors the pattern used
// by Shell.test.ts and other route tests.
vi.mock('$app/state', () => ({
page: { url: new URL('http://localhost/') }
}));
vi.mock('$app/navigation', () => ({
goto: vi.fn()
}));
// auth/store reads $app — stub the bits MobileNavDrawer touches.
vi.mock('$lib/auth/store.svelte', () => ({
user: { value: { username: 'alice', is_admin: false } },
logout: vi.fn().mockResolvedValue(undefined)
}));
import MobileNavDrawer from './MobileNavDrawer.svelte';
import { mobileNav, openMobileNav, closeMobileNav } from '$lib/stores/mobileNav.svelte';
+3 -1
View File
@@ -105,7 +105,9 @@ describe('PlayerBar', () => {
test('play button click calls togglePlay', async () => {
render(PlayerBar);
await fireEvent.click(screen.getByRole('button', { name: /play|pause/i }));
// Anchored regex — "Player options" (the new overflow ⋮) also matches
// /play|pause/i otherwise.
await fireEvent.click(screen.getByRole('button', { name: /^(play|pause)$/i }));
expect(togglePlay).toHaveBeenCalledTimes(1);
});
@@ -9,6 +9,10 @@
onclick: () => void;
danger?: boolean;
disabled?: boolean;
/** Override the accessible name (defaults to label). Use to disambiguate
identical labels across rows for screen readers + tests, e.g.
`Approve {row.title}`. */
ariaLabel?: string;
}
</script>
@@ -46,6 +50,7 @@
<!-- Primary action: always inline -->
<button
type="button"
aria-label={primary.ariaLabel ?? primary.label}
disabled={primary.disabled}
onclick={() => fire(primary)}
class="inline-flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm
@@ -60,6 +65,7 @@
{#each secondary as action (action.label)}
<button
type="button"
aria-label={action.ariaLabel ?? action.label}
disabled={action.disabled}
onclick={() => fire(action)}
class="inline-flex items-center gap-1 rounded-md border px-3 py-1.5 text-sm
+19 -3
View File
@@ -181,10 +181,26 @@
{#each rows as r (r.track_id)}
{@const isExpanded = !!expanded[r.track_id]}
{@const lidarrDisabled = !r.lidarr_album_mbid}
{@const primary: RowAction = { icon: RotateCcw, label: 'Resolve', onclick: () => onResolve(r) }}
{@const primary: RowAction = {
icon: RotateCcw,
label: 'Resolve',
ariaLabel: `Resolve ${r.track_title}`,
onclick: () => onResolve(r)
}}
{@const secondary: RowAction[] = [
{ icon: Play, label: 'Play', onclick: () => onPlay(r) },
{ icon: Trash2, label: 'Delete file', onclick: () => openDeleteFile(r), danger: true }
{
icon: Play,
label: 'Play',
ariaLabel: `Play ${r.track_title}`,
onclick: () => onPlay(r)
},
{
icon: Trash2,
label: 'Delete file',
ariaLabel: `Delete file for ${r.track_title}`,
onclick: () => openDeleteFile(r),
danger: true
}
]}
<li
class="flex items-start gap-4 p-3"
+19 -3
View File
@@ -247,10 +247,26 @@
</div>
{#if r.status === 'pending'}
{@const primary: RowAction = { icon: Check, label: 'Approve', onclick: () => onApprove(r) }}
{@const primary: RowAction = {
icon: Check,
label: 'Approve',
ariaLabel: `Approve ${rowAccessibleName(r)}`,
onclick: () => onApprove(r)
}}
{@const secondary: RowAction[] = [
{ icon: SlidersHorizontal, label: 'Override', onclick: () => openOverride(r) },
{ icon: X, label: 'Reject', onclick: () => openReject(r), danger: true }
{
icon: SlidersHorizontal,
label: 'Override',
ariaLabel: `Override ${rowAccessibleName(r)}`,
onclick: () => openOverride(r)
},
{
icon: X,
label: 'Reject',
ariaLabel: `Reject ${rowAccessibleName(r)}`,
onclick: () => openReject(r),
danger: true
}
]}
<div class="shrink-0">
<RowActionsMenu {primary} {secondary} />
+6
View File
@@ -238,6 +238,7 @@
{@const primary: RowAction = {
icon: u.is_admin ? ShieldOff : Shield,
label: u.is_admin ? 'Remove admin' : 'Make admin',
ariaLabel: u.is_admin ? `Remove admin from ${u.username}` : `Make ${u.username} admin`,
onclick: () => onToggleAdmin(u),
disabled: saving
}}
@@ -245,18 +246,23 @@
{
icon: u.auto_approve_requests ? XCircle : CheckCircle2,
label: u.auto_approve_requests ? 'Disable auto-approve' : 'Enable auto-approve',
ariaLabel: u.auto_approve_requests
? `Disable auto-approve for ${u.username}`
: `Enable auto-approve for ${u.username}`,
onclick: () => onToggleAutoApprove(u),
disabled: saving
},
{
icon: KeyRound,
label: 'Reset password',
ariaLabel: `Reset password for ${u.username}`,
onclick: () => { resetPasswordTarget = u; },
disabled: saving
},
{
icon: Trash2,
label: 'Delete',
ariaLabel: `Delete ${u.username}`,
onclick: () => { confirmDeleteTarget = u; },
disabled: saving,
danger: true