mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 09:08:54 -04:00
fix(addons): let each add-on draw its own page title
The shell no longer titles add-on pages, so each add-on draws one h1 per page. Voice titles its tabs "Voices" (Recognition and From a recording), "Speech voices" (Library) and "Speak text" (Text to speech); "Add a speech voice" already had its own. Face draws "Faces", Studio "Studio" and Talk "Talk". Tables promotes the database list and database name from h2 to h1. Fine-tuning and Quantization already drew a title. The add-on unit tests and the navigation and Studio specs now assert exactly one h1 per page, with the page's own title. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
22 files changed
+59
-32
No files matched your search
@@ -80,9 +80,8 @@ export function createFaceRecognition(ctx, api, identity) {
|
||||
|
||||
return (
|
||||
<main className="page idn-page" data-testid="faces-page">
|
||||
{/* The add-on shell already titles the page "Face", so this header
|
||||
carries the supporting line and the model picker only. */}
|
||||
<ui.PageHeader
|
||||
title={t('faces.title')}
|
||||
supporting={t('faces.lede')}
|
||||
actions={!needModel ? (
|
||||
<div className="idn-modelpick">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Generated once from the host locale files; edit by hand from here on.
|
||||
export default {
|
||||
"faces.title": "Faces",
|
||||
"faces.lede": "Find out whose face is in a photo, or whether two photos show the same person. Photos go only to the model running on this server.",
|
||||
"tool.aria": "Recognition tool",
|
||||
"tool.sameTab": "Same person?",
|
||||
|
||||
@@ -64,7 +64,7 @@ test('recognition: the bundle has the face canvas, the face strings and no host
|
||||
const js = readFileSync(join(dir, 'ui/face.js'), 'utf8')
|
||||
assert.match(js, /biometrics-bbox/)
|
||||
assert.doesNotMatch(js, /from ['"]react(-dom)?/)
|
||||
assert.match(js, /faces\.lede/)
|
||||
assert.match(js, /faces\.title/)
|
||||
})
|
||||
|
||||
test('recognition: every translation key the sources read is in the Face bundle', async () => {
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function load(extra = {}) {
|
||||
addonId: 'face', generation: 1, signal: controller.signal, pages: ['recognition'],
|
||||
host: {
|
||||
React,
|
||||
components: Object.fromEntries(['Button', 'Spinner', 'EmptyState', 'Badge', 'Stack', 'Text', 'PageHeader', 'ConfirmDialog', 'ErrorWithTraceLink', 'ModelSelector', 'MediaInput', 'WaveformPlayer', 'DistributionBars', 'EmbeddingInspector', 'TabSwitch'].map(n => [n, stub(n === 'PageHeader' ? 'header' : 'div')])),
|
||||
components: Object.fromEntries(['Button', 'Spinner', 'EmptyState', 'Badge', 'Stack', 'Text', 'PageHeader', 'ConfirmDialog', 'ErrorWithTraceLink', 'ModelSelector', 'MediaInput', 'WaveformPlayer', 'DistributionBars', 'EmbeddingInspector', 'TabSwitch'].map(n => [n, n === 'PageHeader' ? (p => React.createElement('header', { 'data-stub': 'header' }, p.title ? React.createElement('h1', null, p.title) : null, p.supporting)) : stub('div')])),
|
||||
hooks: { useMediaCapture: () => ({}), useObjectUrl: () => null, useDelayedAction: () => ({ pending: null, schedule() {}, undo() {} }) },
|
||||
viewer: { isAdmin: () => true, hasFeature: () => true },
|
||||
basePath: '',
|
||||
@@ -66,3 +66,10 @@ test('render: a registered page is drawn with its path', async () => {
|
||||
const addon = await mod.createAddon(sdk, { pages: { recognition: ({ path }) => React.createElement('p', null, `page:${path}`) } })
|
||||
assert.equal(renderToString(React.createElement(addon.Component, { page: 'recognition', path: 'abc' })), '<p>page:abc</p>')
|
||||
})
|
||||
|
||||
test('render: the recognition page draws exactly one h1, its own title', async () => {
|
||||
const { mod, sdk } = await load()
|
||||
const addon = await mod.createAddon(sdk)
|
||||
const html = renderToString(React.createElement(addon.Component, { page: 'recognition', path: '' }))
|
||||
assert.deepEqual([...html.matchAll(/<h1[^>]*>(.*?)<\/h1>/g)].map(m => m[1]), ['Faces'])
|
||||
})
|
||||
@@ -24,7 +24,7 @@ export async function load(extra = {}) {
|
||||
const controller = new AbortController()
|
||||
const { viewer, components, layerSpec, fetch, ...rest } = extra
|
||||
const stubs = Object.fromEntries(COMPONENTS.map(n => [n, stub('div')]))
|
||||
stubs.PageHeader = (p) => React.createElement('header', { 'data-stub': 'header' }, p.title, p.supporting, p.actions)
|
||||
stubs.PageHeader = (p) => React.createElement('header', { 'data-stub': 'header' }, p.title ? React.createElement('h1', null, p.title) : null, p.supporting, p.actions)
|
||||
const sdk = createHostSDK({
|
||||
addonId: 'fine-tuning', generation: 1, signal: controller.signal, pages: ['jobs'],
|
||||
host: {
|
||||
|
||||
@@ -47,4 +47,5 @@ test('render: the jobs page is drawn by the real registration path, which binds
|
||||
assert.match(html, /data-testid="ft-bar"/)
|
||||
assert.match(html, /data-testid="ft-jobs"/)
|
||||
assert.match(html, /Fine-tune/)
|
||||
assert.equal((html.match(/<h1[ >]/g) || []).length, 1, 'one title')
|
||||
})
|
||||
@@ -24,7 +24,7 @@ export async function load(extra = {}) {
|
||||
const controller = new AbortController()
|
||||
const { viewer, components, layerSpec, fetch, ...rest } = extra
|
||||
const stubs = Object.fromEntries(COMPONENTS.map(n => [n, stub('div')]))
|
||||
stubs.PageHeader = (p) => React.createElement('header', { 'data-stub': 'header' }, p.title, p.supporting, p.actions)
|
||||
stubs.PageHeader = (p) => React.createElement('header', { 'data-stub': 'header' }, p.title ? React.createElement('h1', null, p.title) : null, p.supporting, p.actions)
|
||||
const sdk = createHostSDK({
|
||||
addonId: 'quantization', generation: 1, signal: controller.signal, pages: ['jobs'],
|
||||
host: {
|
||||
|
||||
@@ -47,4 +47,5 @@ test('render: the jobs page is drawn by the real registration path, which binds
|
||||
assert.match(html, /data-testid="qz-bar"/)
|
||||
assert.match(html, /data-testid="qz-jobs"/)
|
||||
assert.match(html, /Quantize/)
|
||||
assert.equal((html.match(/<h1[ >]/g) || []).length, 1, 'one title')
|
||||
})
|
||||
@@ -80,9 +80,8 @@ export default function StudioOverview({ modalities, upscalers, modelsLoading, m
|
||||
|
||||
return (
|
||||
<div data-testid="studio-overview" className="page-pad studio-front">
|
||||
{/* The add-on shell already titles the page "Studio", so this header
|
||||
carries the supporting line and the key hint only. */}
|
||||
<PageHeader
|
||||
title={t('studio.overview.title')}
|
||||
supporting={t('studio.overview.subtitle')}
|
||||
actions={<span className="studio-key-hint"><kbd className="dk-kbd">/</kbd> {t('studio.composer.startTyping')}</span>}
|
||||
/>
|
||||
|
||||
@@ -100,7 +100,7 @@ export function createDatabase(ctx, ui, api, hooks, parts) {
|
||||
<header className="tbl-head">
|
||||
<div className="tbl-head__main">
|
||||
<div>{back}</div>
|
||||
<h2 className="tbl-title" data-testid="tbl-db-name">{name}</h2>
|
||||
<h1 className="tbl-title" data-testid="tbl-db-name">{name}</h1>
|
||||
<div className="tbl-db__meta">
|
||||
<RoleBadge role={resource.role} />
|
||||
{!isOwner && resource.owner ? <span className="tbl-muted">{t('db.sharedBy', { name: resource.owner })}</span> : null}
|
||||
|
||||
@@ -179,7 +179,7 @@ export function createDatabases(ctx, ui, api, hooks) {
|
||||
<section className="tbl-page" data-testid="tbl-list-page" aria-labelledby="tbl-list-title">
|
||||
<header className="tbl-head">
|
||||
<div>
|
||||
<h2 id="tbl-list-title" className="tbl-title">{t('list.heading')}</h2>
|
||||
<h1 id="tbl-list-title" className="tbl-title">{t('list.heading')}</h1>
|
||||
<p className="tbl-muted">{t('list.intro')}</p>
|
||||
</div>
|
||||
{canCreate && data && data.resources.length > 0 ? <Button variant="primary" icon="plus" onClick={create}>{t('list.new')}</Button> : null}
|
||||
|
||||
@@ -58,6 +58,7 @@ test('render: the list, one database and the picker draw their loading state wit
|
||||
const addon = await mod.createAddon(sdk)
|
||||
const list = renderToString(React.createElement(addon.Component, { page: 'databases', path: '' }))
|
||||
assert.match(list, /Your databases/)
|
||||
assert.deepEqual([...list.matchAll(/<h1[^>]*>(.*?)<\/h1>/g)].map(m => m[1]), ['Your databases'])
|
||||
assert.match(list, /data-addon="tables"/)
|
||||
const one = renderToString(React.createElement(addon.Component, { page: 'databases', path: 'r0123456789abcdef0123456789abcde' }))
|
||||
assert.match(one, /tbl-loading/)
|
||||
|
||||
@@ -716,7 +716,7 @@ export default function Talk() {
|
||||
return (
|
||||
<div className="talk-page" data-view={view}>
|
||||
<header className="talk-hd">
|
||||
{/* The add-on shell already titles the page "Talk". */}
|
||||
<h1 className="page-title talk-hd__title">{t('title')}</h1>
|
||||
<span className="talk-hd__sub">{t('subtitle')}</span>
|
||||
<div className="talk-hd__acts">
|
||||
{isConnected && (
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
.talk-page a:focus-visible,
|
||||
.talk-page input:focus-visible { outline: 2px solid var(--dk-accent-text); outline-offset: 2px; }
|
||||
.talk-hd { display: flex; align-items: center; gap: 12px; min-height: 56px; padding: 8px 0; }
|
||||
.talk-hd__title { margin: 0; font-size: var(--dk-text-20, 20px); font-weight: var(--dk-weight-semibold, 600); }
|
||||
.talk-hd__sub { color: var(--dk-muted); font-family: var(--font-mono); font-size: 12px; }
|
||||
.talk-hd__acts { display: flex; align-items: center; gap: 2px; margin-left: auto; }
|
||||
.talk-icobtn {
|
||||
|
||||
@@ -53,3 +53,10 @@ test('render: the manifest declares one page, the main page and SDK 2', () => {
|
||||
assert.deepEqual(m.capabilities.ui.sdk, { min: 2, max: 2 })
|
||||
assert.deepEqual(m.capabilities.tools ?? [], [])
|
||||
})
|
||||
|
||||
test('render: the page draws exactly one h1, its own title', async () => {
|
||||
const { mod, sdk } = await load()
|
||||
const addon = await mod.createAddon(sdk)
|
||||
const html = renderToString(React.createElement(addon.Component, { page: 'talk', path: '', search: '' }))
|
||||
assert.deepEqual([...html.matchAll(/<h1[^>]*>(.*?)<\/h1>/g)].map(m => m[1]), ['Talk'])
|
||||
})
|
||||
@@ -56,8 +56,7 @@ export function createSpeech(ctx) {
|
||||
|
||||
return (
|
||||
<main className="page vc-speech" data-testid="voice-speech">
|
||||
{/* The add-on shell already titles the page. */}
|
||||
<ui.PageHeader supporting={t('speech.lede')} />
|
||||
<ui.PageHeader title={t('speech.title')} supporting={t('speech.lede')} />
|
||||
{tabs}
|
||||
<label className="vc-field">
|
||||
<span>{t('speech.model')}</span>
|
||||
|
||||
@@ -365,9 +365,8 @@ JSON`
|
||||
|
||||
return (
|
||||
<main className="page voice-library-page" data-testid="voice-library">
|
||||
{/* The add-on shell already titles the page, so this header carries the
|
||||
supporting line and the actions only. */}
|
||||
<ui.PageHeader
|
||||
title={<>{t('voiceLibrary.title')} <span className="dk-hubtab-count">{profiles.length}</span></>}
|
||||
supporting={t('voiceLibrary.subtitle')}
|
||||
actions={(
|
||||
<div className="idn-speech__acts">
|
||||
|
||||
@@ -79,9 +79,8 @@ export function createVoiceRecognition(ctx, api, identity) {
|
||||
|
||||
return (
|
||||
<main className="page idn-page" data-testid="voices-page">
|
||||
{/* The add-on shell already titles the page "Voice", so this header
|
||||
carries the supporting line and the model picker only. */}
|
||||
<ui.PageHeader
|
||||
title={t('voices.title')}
|
||||
supporting={t('voices.lede')}
|
||||
actions={tab === 'speakers' && !needModel ? (
|
||||
<div className="idn-modelpick">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
export default {
|
||||
"voice.adminOnly.title": "Administrators only",
|
||||
"voice.adminOnly.text": "Saved voices can be managed by an administrator of this server. Ask one if you need a voice added or removed.",
|
||||
"speech.title": "Speak text",
|
||||
"speech.lede": "Turn text into speech with a voice model of this server.",
|
||||
"speech.model": "Model",
|
||||
"speech.voice": "Voice",
|
||||
@@ -11,6 +12,7 @@ export default {
|
||||
"speech.generate": "Generate speech",
|
||||
"speech.result": "Result",
|
||||
"speech.noAccess": "Your account has no access to text-to-speech. The server will refuse the request.",
|
||||
"voices.title": "Voices",
|
||||
"voices.lede": "Recognise who is speaking, and keep voices that text-to-speech can use.",
|
||||
"voices.tabs": "Voices sections",
|
||||
"voices.tab.recognition": "Recognition",
|
||||
@@ -157,6 +159,7 @@ export default {
|
||||
"tts.voiceLibrary.create": "Create one",
|
||||
"tts.voiceLibrary.manage": "Manage Personality Library",
|
||||
"tts.voiceLibrary.namedVoiceHint": "This model does not accept reference-audio profiles. You can still enter a named speaker supported by its backend.",
|
||||
"voiceLibrary.title": "Speech voices",
|
||||
"voiceLibrary.subtitle": "A recording and its exact transcript. Text-to-speech models that accept reference audio can speak with it.",
|
||||
"voiceLibrary.loadFailed": "Speech voices could not be loaded",
|
||||
"voiceLibrary.loading": "Loading saved personalities…",
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function load(extra = {}) {
|
||||
addonId: 'voice', generation: 1, signal: controller.signal, pages: ['voice', 'recognition', 'library', 'create', 'speech'],
|
||||
host: {
|
||||
React,
|
||||
components: Object.fromEntries(['Button', 'Spinner', 'EmptyState', 'Badge', 'Stack', 'Text', 'PageHeader', 'ConfirmDialog', 'ErrorWithTraceLink', 'ModelSelector', 'MediaInput', 'WaveformPlayer', 'DistributionBars', 'EmbeddingInspector', 'TabSwitch'].map(n => [n, stub(n === 'PageHeader' ? 'header' : 'div')])),
|
||||
components: Object.fromEntries(['Button', 'Spinner', 'EmptyState', 'Badge', 'Stack', 'Text', 'PageHeader', 'ConfirmDialog', 'ErrorWithTraceLink', 'ModelSelector', 'MediaInput', 'WaveformPlayer', 'DistributionBars', 'EmbeddingInspector', 'TabSwitch'].map(n => [n, n === 'PageHeader' ? (p => React.createElement('header', { 'data-stub': 'header' }, p.title ? React.createElement('h1', null, p.title) : null, p.supporting)) : stub('div')])),
|
||||
hooks: { useMediaCapture: () => ({}), useObjectUrl: () => null, useDelayedAction: () => ({ pending: null, schedule() {}, undo() {} }) },
|
||||
viewer: { isAdmin: () => true, hasFeature: () => true },
|
||||
basePath: '',
|
||||
@@ -83,3 +83,14 @@ test('render: an older page name draws nothing itself; the shell sends it to its
|
||||
assert.equal(renderToString(React.createElement(addon.Component, { page, path: '' })), '', page)
|
||||
}
|
||||
})
|
||||
|
||||
test('render: each tab draws exactly one h1, its own title', async () => {
|
||||
const { mod, sdk } = await load()
|
||||
const addon = await mod.createAddon(sdk)
|
||||
const h1s = (html) => [...html.matchAll(/<h1[^>]*>(.*?)<\/h1>/g)].map(m => m[1].replace(/<[^>]+>/g, '').trim())
|
||||
assert.deepEqual(h1s(renderToString(React.createElement(addon.Component, { page: 'voice', path: '' }))), ['Voices'])
|
||||
assert.deepEqual(h1s(renderToString(React.createElement(addon.Component, { page: 'voice', path: 'recording' }))), ['Voices'])
|
||||
assert.deepEqual(h1s(renderToString(React.createElement(addon.Component, { page: 'voice', path: 'speech' }))), ['Speak text'])
|
||||
assert.deepEqual(h1s(renderToString(React.createElement(addon.Component, { page: 'voice', path: 'library' }))), ['Speech voices 0'])
|
||||
assert.deepEqual(h1s(renderToString(React.createElement(addon.Component, { page: 'voice', path: 'library/new' }))), ['Add a speech voice'])
|
||||
})
|
||||
@@ -405,11 +405,11 @@ async function expectVoiceTabs(page, active) {
|
||||
await expect(tabs.locator('[aria-selected="true"]')).toHaveAttribute('data-tab', active)
|
||||
}
|
||||
|
||||
// The add-on shell shows the one title, "Voice"; the Library, Text to speech and
|
||||
// From a recording tabs do not repeat it with a heading of their own.
|
||||
async function expectOneVoiceTitle(page) {
|
||||
// The add-on draws its own title, once: the shell adds none, and each tab has
|
||||
// exactly one h1.
|
||||
async function expectOneVoiceTitle(page, title) {
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1)
|
||||
await expect(page.getByRole('heading', { name: 'Voice', exact: true, level: 1 })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: title, level: 1 })).toBeVisible()
|
||||
}
|
||||
|
||||
test.describe('Add-on navigation: the Voice recognition page', () => {
|
||||
@@ -420,9 +420,7 @@ test.describe('Add-on navigation: the Voice recognition page', () => {
|
||||
await mockIdentity(page)
|
||||
await page.goto('/app/addons/voice/voice/recognition')
|
||||
await expect(page.getByTestId('voices-page')).toBeVisible()
|
||||
// The add-on shell shows the one title; the page does not repeat it.
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1)
|
||||
await expect(page.getByRole('heading', { name: 'Voice', exact: true, level: 1 })).toBeVisible()
|
||||
await expectOneVoiceTitle(page, 'Voices')
|
||||
await expectVoiceTabs(page, 'recognition')
|
||||
await expect(page.locator('[data-testid="addon-surface"][data-addon="voice"]')).toBeVisible()
|
||||
await expect(page.getByTestId('known-empty')).toContainText('Nobody enrolled in this browser yet')
|
||||
@@ -472,24 +470,25 @@ test.describe('Add-on navigation: the Voice recognition page', () => {
|
||||
await expect(page).toHaveURL(/\/app\/addons\/voice\/voice\/library$/)
|
||||
await expect(page.getByTestId('voice-library')).toBeVisible()
|
||||
await expectVoiceTabs(page, 'library')
|
||||
await expectOneVoiceTitle(page)
|
||||
await expectOneVoiceTitle(page, /^Speech voices/)
|
||||
|
||||
await page.getByTestId('voice-library-new').first().click()
|
||||
await expect(page).toHaveURL(/\/app\/addons\/voice\/voice\/library\/new$/)
|
||||
await expect(page.getByTestId('voice-profile-create')).toBeVisible()
|
||||
await expectVoiceTabs(page, 'library')
|
||||
await expectOneVoiceTitle(page, /Add a speech voice/)
|
||||
|
||||
await tabs.getByRole('tab', { name: /^Text to speech$/ }).click()
|
||||
await expect(page).toHaveURL(/\/app\/addons\/voice\/voice\/speech$/)
|
||||
await expect(page.getByTestId('voice-speech')).toBeVisible()
|
||||
await expectVoiceTabs(page, 'speech')
|
||||
await expectOneVoiceTitle(page)
|
||||
await expectOneVoiceTitle(page, 'Speak text')
|
||||
|
||||
await tabs.getByRole('tab', { name: /^From a recording$/ }).click()
|
||||
await expect(page).toHaveURL(/\/app\/addons\/voice\/voice\/recording$/)
|
||||
await expect(page.getByTestId('recording-tab')).toBeVisible()
|
||||
await expectVoiceTabs(page, 'recording')
|
||||
await expectOneVoiceTitle(page)
|
||||
await expectOneVoiceTitle(page, 'Voices')
|
||||
})
|
||||
|
||||
test('a viewer who is not an administrator gets no Library tab', async ({ page }) => {
|
||||
@@ -622,8 +621,7 @@ async function asFaceAdmin(page, features = {}) {
|
||||
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN, { addons: true, face_recognition: true, ...features }) }))
|
||||
}
|
||||
|
||||
// The core page titles itself "Faces"; inside the add-on shell the shell shows
|
||||
// the one title, "Face", and the page does not repeat it.
|
||||
// The page draws its own title "Faces", once, in the core and in the add-on.
|
||||
async function expectFacesPage(page, title = 'Faces') {
|
||||
await expect(page.getByTestId('faces-page')).toBeVisible()
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1)
|
||||
@@ -637,7 +635,7 @@ test.describe('Add-on navigation: the Face recognition page', () => {
|
||||
await serveFace(page)
|
||||
await mockIdentity(page)
|
||||
await page.goto('/app/addons/face/recognition')
|
||||
await expectFacesPage(page, 'Face')
|
||||
await expectFacesPage(page)
|
||||
await expect(page.locator('[data-testid="addon-surface"][data-addon="face"]')).toBeVisible()
|
||||
await expect(page.getByTestId('known-empty')).toContainText('Nobody enrolled in this browser yet')
|
||||
})
|
||||
|
||||
@@ -28,10 +28,11 @@ test.describe('Studio add-on', () => {
|
||||
})
|
||||
}
|
||||
|
||||
test('the shell titles the page once, and the page keeps its own words from the media locale', async ({ page }) => {
|
||||
test('the page draws its own title once, and the page keeps its own words from the media locale', async ({ page }) => {
|
||||
await mockStudio(page)
|
||||
await page.goto('/app/addons/studio/studio')
|
||||
await expect(page.getByTestId('studio-overview')).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.locator('h1')).toHaveCount(1)
|
||||
await expect(page.locator('h1.page-title', { hasText: /^Studio$/ })).toHaveCount(1)
|
||||
await expect(page.getByText('What do you want to make?')).toBeVisible()
|
||||
// No raw key is drawn where words belong.
|
||||
|
||||
Reference in new issue
Block a user