feat(web): tag enrichment sources admin card (#1490 Step 4 web)
Add a "Tag enrichment sources" card to the admin integrations page, mirroring the cover-art providers card: per-provider enable toggle + API-key field + Save + Test connection, over /api/admin/tag-sources. Enabling/keying a source (e.g. pasting a Last.fm key) re-opens settled tracks for re-enrichment via the version bump. - admin.ts: TagProvider types + get/update/test functions + createTagProvidersQuery; qk.tagProviders key. - integrations/+page.svelte: the card + local edit state, with MusicBrainz (keyless baseline) and Last.fm (needs a free key) notes. - Tests: admin.tag-sources API test + integrations component tests (render / enable+key save / test connection), plus the mock plumbing the existing suite needs for the new query. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
getTagProviders,
|
||||
updateTagProvider,
|
||||
testTagProvider,
|
||||
type TagProvider,
|
||||
type TagProvidersResponse
|
||||
} from './admin';
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn() }
|
||||
}));
|
||||
|
||||
import { api } from './client';
|
||||
|
||||
describe('admin tag-sources API', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('getTagProviders GETs the correct path', async () => {
|
||||
const sample: TagProvidersResponse = {
|
||||
providers: [
|
||||
{
|
||||
id: 'musicbrainz',
|
||||
display_name: 'MusicBrainz',
|
||||
requires_api_key: false,
|
||||
supports: ['track_tags'],
|
||||
enabled: true,
|
||||
api_key_set: false,
|
||||
display_order: 0,
|
||||
testable: true
|
||||
}
|
||||
],
|
||||
sources_version: 1
|
||||
};
|
||||
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
|
||||
const got = await getTagProviders();
|
||||
expect(api.get).toHaveBeenCalledWith('/api/admin/tag-sources');
|
||||
expect(got.providers[0].id).toBe('musicbrainz');
|
||||
expect(got.sources_version).toBe(1);
|
||||
});
|
||||
|
||||
it('updateTagProvider PATCHes the right path with patch body', async () => {
|
||||
const patched: TagProvider & { version_bumped: boolean } = {
|
||||
id: 'lastfm',
|
||||
display_name: 'Last.fm',
|
||||
requires_api_key: true,
|
||||
supports: ['track_tags'],
|
||||
enabled: true,
|
||||
api_key_set: true,
|
||||
display_order: 1,
|
||||
testable: true,
|
||||
version_bumped: true
|
||||
};
|
||||
(api.patch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(patched);
|
||||
const got = await updateTagProvider('lastfm', { enabled: true, api_key: 'mykey' });
|
||||
expect(api.patch).toHaveBeenCalledWith(
|
||||
'/api/admin/tag-sources/lastfm',
|
||||
{ enabled: true, api_key: 'mykey' }
|
||||
);
|
||||
expect(got.api_key_set).toBe(true);
|
||||
expect(got.version_bumped).toBe(true);
|
||||
});
|
||||
|
||||
it('testTagProvider POSTs to /test endpoint with empty body', async () => {
|
||||
(api.post as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
duration_ms: 55
|
||||
});
|
||||
const got = await testTagProvider('musicbrainz');
|
||||
expect(api.post).toHaveBeenCalledWith('/api/admin/tag-sources/musicbrainz/test', {});
|
||||
expect(got.ok).toBe(true);
|
||||
expect(got.duration_ms).toBe(55);
|
||||
});
|
||||
|
||||
it('testTagProvider returns ok=false with error string on failure', async () => {
|
||||
(api.post as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: 'lastfm api key not set'
|
||||
});
|
||||
const got = await testTagProvider('lastfm');
|
||||
expect(got.ok).toBe(false);
|
||||
expect(got.error).toBe('lastfm api key not set');
|
||||
});
|
||||
});
|
||||
@@ -383,6 +383,67 @@ export function createCoverProvidersQuery() {
|
||||
});
|
||||
}
|
||||
|
||||
// Tag-enrichment providers -------------------------------------------------
|
||||
// Parallel to the cover-art providers surface, over /api/admin/tag-sources.
|
||||
// Independent settings so a new folksonomy source is added without touching
|
||||
// art config (#1490).
|
||||
|
||||
export type TagProviderCapability = 'track_tags';
|
||||
|
||||
export type TagProvider = {
|
||||
id: string;
|
||||
display_name: string;
|
||||
requires_api_key: boolean;
|
||||
supports: TagProviderCapability[];
|
||||
enabled: boolean;
|
||||
api_key_set: boolean;
|
||||
display_order: number;
|
||||
testable: boolean;
|
||||
};
|
||||
|
||||
export type TagProvidersResponse = {
|
||||
providers: TagProvider[];
|
||||
sources_version: number;
|
||||
};
|
||||
|
||||
export async function getTagProviders(): Promise<TagProvidersResponse> {
|
||||
return api.get<TagProvidersResponse>('/api/admin/tag-sources');
|
||||
}
|
||||
|
||||
export type UpdateTagProviderPatch = {
|
||||
enabled?: boolean;
|
||||
api_key?: string;
|
||||
};
|
||||
|
||||
export type UpdateTagProviderResponse = TagProvider & {
|
||||
version_bumped: boolean;
|
||||
};
|
||||
|
||||
export async function updateTagProvider(
|
||||
id: string,
|
||||
patch: UpdateTagProviderPatch
|
||||
): Promise<UpdateTagProviderResponse> {
|
||||
return api.patch<UpdateTagProviderResponse>(`/api/admin/tag-sources/${id}`, patch);
|
||||
}
|
||||
|
||||
export type TestTagProviderResponse = {
|
||||
ok: boolean;
|
||||
duration_ms?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export async function testTagProvider(id: string): Promise<TestTagProviderResponse> {
|
||||
return api.post<TestTagProviderResponse>(`/api/admin/tag-sources/${id}/test`, {});
|
||||
}
|
||||
|
||||
export function createTagProvidersQuery() {
|
||||
return createQuery({
|
||||
queryKey: qk.tagProviders(),
|
||||
queryFn: getTagProviders,
|
||||
staleTime: 60_000
|
||||
});
|
||||
}
|
||||
|
||||
// Admin user-management ------------------------------------------------------
|
||||
|
||||
export type AdminUser = {
|
||||
|
||||
@@ -48,6 +48,7 @@ export const qk = {
|
||||
scanStatus: () => ['scanStatus'] as const,
|
||||
coverage: () => ['coverage'] as const,
|
||||
coverProviders: () => ['coverProviders'] as const,
|
||||
tagProviders: () => ['tagProviders'] as const,
|
||||
adminUsers: () => ['adminUsers'] as const,
|
||||
adminInvites: () => ['adminInvites'] as const,
|
||||
adminDiagnostics: (f: Record<string, string | number | undefined>) =>
|
||||
|
||||
@@ -13,10 +13,14 @@
|
||||
createCoverProvidersQuery,
|
||||
updateCoverProvider,
|
||||
testCoverProvider,
|
||||
createTagProvidersQuery,
|
||||
updateTagProvider,
|
||||
testTagProvider,
|
||||
updateSMTPConfig,
|
||||
testSMTPConfig,
|
||||
createSMTPConfigQuery,
|
||||
type CoverProvider,
|
||||
type TagProvider,
|
||||
type SMTPConfig
|
||||
} from '$lib/api/admin';
|
||||
import { qk } from '$lib/api/queries';
|
||||
@@ -280,6 +284,62 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Tag enrichment providers ----
|
||||
// Mirrors the cover-art providers panel over /api/admin/tag-sources (#1490).
|
||||
const tagProvidersStore = $derived(createTagProvidersQuery());
|
||||
const tagProvidersQ = $derived($tagProvidersStore);
|
||||
|
||||
let tagLocalState = $state<Record<string, { enabled: boolean; apiKey: string }>>({});
|
||||
let tagTestResults = $state<Record<string, { ok: boolean; duration_ms?: number; error?: string }>>({});
|
||||
let tagSaving = $state<Record<string, boolean>>({});
|
||||
let tagTesting = $state<Record<string, boolean>>({});
|
||||
|
||||
$effect(() => {
|
||||
if (tagProvidersQ.data) {
|
||||
for (const p of tagProvidersQ.data.providers) {
|
||||
if (!tagLocalState[p.id]) {
|
||||
tagLocalState[p.id] = { enabled: p.enabled, apiKey: '' };
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function tagHasChanges(provider: TagProvider): boolean {
|
||||
const local = tagLocalState[provider.id];
|
||||
if (!local) return false;
|
||||
return provider.enabled !== local.enabled || local.apiKey !== '';
|
||||
}
|
||||
|
||||
async function tagSave(provider: TagProvider) {
|
||||
const local = tagLocalState[provider.id];
|
||||
if (!local || !tagHasChanges(provider)) return;
|
||||
const patch: { enabled?: boolean; api_key?: string } = {};
|
||||
if (provider.enabled !== local.enabled) patch.enabled = local.enabled;
|
||||
if (local.apiKey !== '') patch.api_key = local.apiKey;
|
||||
|
||||
tagSaving[provider.id] = true;
|
||||
try {
|
||||
const result = await updateTagProvider(provider.id, patch);
|
||||
tagLocalState[provider.id] = { enabled: result.enabled, apiKey: '' };
|
||||
await client.invalidateQueries({ queryKey: qk.tagProviders() });
|
||||
} catch (_e) {
|
||||
// Inline error surfacing handled by the mutation toast layer; swallow here.
|
||||
} finally {
|
||||
tagSaving[provider.id] = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function tagTest(provider: TagProvider) {
|
||||
tagTesting[provider.id] = true;
|
||||
try {
|
||||
tagTestResults[provider.id] = await testTagProvider(provider.id);
|
||||
} catch (e) {
|
||||
tagTestResults[provider.id] = { ok: false, error: (e as Error).message };
|
||||
} finally {
|
||||
tagTesting[provider.id] = false;
|
||||
}
|
||||
}
|
||||
|
||||
// SMTP config -------------------------------------------------------------
|
||||
|
||||
const smtpStore = $derived(createSMTPConfigQuery());
|
||||
@@ -597,6 +657,108 @@
|
||||
<p class="text-sm text-text-muted">No providers registered.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Tag enrichment providers -->
|
||||
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
|
||||
<div>
|
||||
<h3 class="font-display text-lg font-medium text-text-primary">Tag enrichment sources</h3>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
Folksonomy style/mood tags fetched per track and folded into each
|
||||
listener's taste profile alongside the raw file genre — so "Rock"
|
||||
gains "post-punk / shoegaze / melancholic". All enabled sources are
|
||||
merged. Enabling or disabling a source re-opens previously-processed
|
||||
tracks for a fresh pass.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if tagProvidersQ.data}
|
||||
{#each tagProvidersQ.data.providers as provider (provider.id)}
|
||||
<article class="rounded-lg border border-border bg-background p-4 space-y-3">
|
||||
<header class="flex items-baseline justify-between gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<h4 class="font-display text-base font-medium text-text-primary">{provider.display_name}</h4>
|
||||
<span
|
||||
class="h-1.5 w-1.5 rounded-full {provider.enabled ? 'bg-action-primary' : 'bg-text-muted'}"
|
||||
title={provider.enabled ? 'Active' : 'Disabled'}
|
||||
></span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each provider.supports as cap}
|
||||
<span class="rounded bg-surface px-1.5 py-0.5 text-xs text-text-secondary">{cap.replace('_', ' ')}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if tagLocalState[provider.id]}
|
||||
<div class="grid gap-2 sm:grid-cols-[auto_1fr] items-center">
|
||||
<label class="text-sm text-text-secondary" for="tag-enabled-{provider.id}">Enabled</label>
|
||||
<div>
|
||||
<input id="tag-enabled-{provider.id}" type="checkbox"
|
||||
bind:checked={tagLocalState[provider.id].enabled} />
|
||||
</div>
|
||||
|
||||
{#if provider.requires_api_key}
|
||||
<label class="text-sm text-text-secondary" for="tag-key-{provider.id}">API key</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input id="tag-key-{provider.id}" type="password"
|
||||
placeholder={provider.api_key_set ? '••• (saved — leave empty to keep)' : 'Paste your API key to enable this source'}
|
||||
bind:value={tagLocalState[provider.id].apiKey}
|
||||
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent flex-1" />
|
||||
{#if provider.api_key_set}
|
||||
<span class="text-xs text-text-secondary whitespace-nowrap">✓ Set</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 pt-1">
|
||||
<button type="button"
|
||||
disabled={!tagHasChanges(provider) || tagSaving[provider.id]}
|
||||
onclick={() => tagSave(provider)}
|
||||
class="inline-flex items-center gap-2 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:opacity-50">
|
||||
{tagSaving[provider.id] ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
{#if provider.testable}
|
||||
<button type="button"
|
||||
disabled={tagTesting[provider.id]}
|
||||
onclick={() => tagTest(provider)}
|
||||
class="inline-flex items-center gap-2 rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50">
|
||||
{tagTesting[provider.id] ? 'Testing…' : 'Test connection'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if tagTestResults[provider.id]}
|
||||
{#if tagTestResults[provider.id].ok}
|
||||
<p class="text-sm text-action-primary">
|
||||
OK{tagTestResults[provider.id].duration_ms ? ` (${tagTestResults[provider.id].duration_ms}ms)` : ''}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-sm text-error">
|
||||
Failed — {tagTestResults[provider.id].error}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if provider.id === 'musicbrainz'}
|
||||
<p class="text-xs text-text-muted">
|
||||
Keyless and on by default — the always-available baseline. Matches tags by
|
||||
recording MBID, so coverage tracks how well your library is MusicBrainz-tagged.
|
||||
</p>
|
||||
{:else if provider.id === 'lastfm'}
|
||||
<p class="text-xs text-text-muted">
|
||||
Name-based (artist + title), so it covers tracks without an MBID. Requires a free
|
||||
API key — get one at <a class="underline" href="https://www.last.fm/api/account/create" target="_blank" rel="noopener noreferrer">last.fm/api</a>.
|
||||
</p>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
{:else if tagProvidersQ.isPending}
|
||||
<p class="text-sm text-text-muted">Loading…</p>
|
||||
{:else}
|
||||
<p class="text-sm text-text-muted">No providers registered.</p>
|
||||
{/if}
|
||||
</section>
|
||||
<!-- SMTP / email config -->
|
||||
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
|
||||
<div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor, within } from '@testing-library/svelte';
|
||||
import { mockQuery } from '../../../test-utils/query';
|
||||
import type { LidarrConfig } from '$lib/api/types';
|
||||
import type { CoverProvider, SMTPConfig } from '$lib/api/admin';
|
||||
import type { CoverProvider, TagProvider, SMTPConfig } from '$lib/api/admin';
|
||||
|
||||
// useQueryClient is wrapped so the page can call invalidateQueries() without
|
||||
// a real QueryClient context. Per-file mock OVERRIDES the vitest.setup default
|
||||
@@ -23,6 +23,9 @@ vi.mock('$lib/api/admin', () => ({
|
||||
createCoverProvidersQuery: vi.fn(),
|
||||
updateCoverProvider: vi.fn(),
|
||||
testCoverProvider: vi.fn(),
|
||||
createTagProvidersQuery: vi.fn(),
|
||||
updateTagProvider: vi.fn(),
|
||||
testTagProvider: vi.fn(),
|
||||
createSMTPConfigQuery: vi.fn(),
|
||||
updateSMTPConfig: vi.fn(),
|
||||
testSMTPConfig: vi.fn()
|
||||
@@ -39,6 +42,9 @@ import {
|
||||
createCoverProvidersQuery,
|
||||
updateCoverProvider,
|
||||
testCoverProvider,
|
||||
createTagProvidersQuery,
|
||||
updateTagProvider,
|
||||
testTagProvider,
|
||||
createSMTPConfigQuery,
|
||||
updateSMTPConfig,
|
||||
testSMTPConfig
|
||||
@@ -95,6 +101,34 @@ const defaultCoverProviders = {
|
||||
sources_version: 1
|
||||
};
|
||||
|
||||
// Fixture tag providers
|
||||
const providerMusicbrainz: TagProvider = {
|
||||
id: 'musicbrainz',
|
||||
display_name: 'MusicBrainz',
|
||||
requires_api_key: false,
|
||||
supports: ['track_tags'],
|
||||
enabled: true,
|
||||
api_key_set: false,
|
||||
display_order: 0,
|
||||
testable: true
|
||||
};
|
||||
|
||||
const providerLastfm: TagProvider = {
|
||||
id: 'lastfm',
|
||||
display_name: 'Last.fm',
|
||||
requires_api_key: true,
|
||||
supports: ['track_tags'],
|
||||
enabled: false,
|
||||
api_key_set: false,
|
||||
display_order: 1,
|
||||
testable: true
|
||||
};
|
||||
|
||||
const defaultTagProviders = {
|
||||
providers: [providerMusicbrainz, providerLastfm],
|
||||
sources_version: 1
|
||||
};
|
||||
|
||||
const testResponseOk = {
|
||||
ok: true as const,
|
||||
version: '2.0.5',
|
||||
@@ -133,7 +167,7 @@ afterEach(() => {
|
||||
invalidateQueries.mockReset();
|
||||
});
|
||||
|
||||
function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCoverProviders | null; smtp?: SMTPConfig } = {}) {
|
||||
function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCoverProviders | null; tagProviders?: typeof defaultTagProviders | null; smtp?: SMTPConfig } = {}) {
|
||||
(createLidarrConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockQuery({ data: opts.config ?? cfgConnected })
|
||||
);
|
||||
@@ -160,6 +194,10 @@ function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCov
|
||||
(createCoverProvidersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockQuery({ data: cpData, isPending: opts.coverProviders === null })
|
||||
);
|
||||
const tpData = opts.tagProviders === null ? undefined : (opts.tagProviders ?? defaultTagProviders);
|
||||
(createTagProvidersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockQuery({ data: tpData, isPending: opts.tagProviders === null })
|
||||
);
|
||||
(createSMTPConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockQuery({ data: opts.smtp ?? smtpUnset })
|
||||
);
|
||||
@@ -195,6 +233,16 @@ function providerCard(providerName: string): HTMLElement {
|
||||
.closest('article') as HTMLElement;
|
||||
}
|
||||
|
||||
function tagProvidersSection(): HTMLElement {
|
||||
return screen.getByRole('heading', { name: /tag enrichment sources/i, level: 3 }).closest('section') as HTMLElement;
|
||||
}
|
||||
|
||||
function tagProviderCard(providerName: string): HTMLElement {
|
||||
return within(tagProvidersSection())
|
||||
.getByRole('heading', { name: providerName, level: 4 })
|
||||
.closest('article') as HTMLElement;
|
||||
}
|
||||
|
||||
describe('/admin/integrations', () => {
|
||||
test('Save calls putLidarrConfig with empty api_key when input is blank', async () => {
|
||||
setup();
|
||||
@@ -641,3 +689,48 @@ describe('Lidarr first-time setup (Test-then-Save)', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/integrations tag sources', () => {
|
||||
test('renders tag enrichment providers', () => {
|
||||
setup();
|
||||
const section = tagProvidersSection();
|
||||
expect(within(section).getByRole('heading', { name: 'MusicBrainz', level: 4 })).toBeInTheDocument();
|
||||
expect(within(section).getByRole('heading', { name: 'Last.fm', level: 4 })).toBeInTheDocument();
|
||||
expect(within(section).getAllByText('track tags').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('enabling Last.fm and typing a key calls updateTagProvider', async () => {
|
||||
setup();
|
||||
(updateTagProvider as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...providerLastfm,
|
||||
enabled: true,
|
||||
api_key_set: true,
|
||||
version_bumped: true
|
||||
});
|
||||
const card = tagProviderCard('Last.fm');
|
||||
const enabledCheckbox = card.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
await fireEvent.click(enabledCheckbox);
|
||||
const keyInput = within(card).getByLabelText(/api key/i);
|
||||
await fireEvent.input(keyInput, { target: { value: 'lfmkey' } });
|
||||
await fireEvent.click(within(card).getByRole('button', { name: /save changes/i }));
|
||||
|
||||
expect(updateTagProvider).toHaveBeenCalledWith(
|
||||
'lastfm',
|
||||
expect.objectContaining({ enabled: true, api_key: 'lfmkey' })
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(invalidateQueries).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ queryKey: ['tagProviders'] })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('Test connection on a tag provider calls testTagProvider and shows OK', async () => {
|
||||
setup();
|
||||
(testTagProvider as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true, duration_ms: 42 });
|
||||
const card = tagProviderCard('MusicBrainz');
|
||||
await fireEvent.click(within(card).getByRole('button', { name: /test connection/i }));
|
||||
await waitFor(() => expect(testTagProvider).toHaveBeenCalledWith('musicbrainz'));
|
||||
await waitFor(() => expect(within(card).getByText(/ok.*42ms/i)).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user