mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 09:08:54 -04:00
test(addons): cover the Voice tabs and the old page redirects
The add-on unit tests render the tabs instead of the four pages. The Playwright spec checks the tab strip, the address of each tab, the missing Library tab for a member, and that the old page addresses of the add-on redirect to their tab with the query string kept. The migration table test follows the new targets. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
6 files changed
+136
-31
No files matched your search
@@ -13,14 +13,14 @@ const { renderToString } = require('react-dom/server')
|
||||
test('library: the first paint of the library page has the core test id', async () => {
|
||||
const { mod, sdk } = await load()
|
||||
const addon = await mod.createAddon(sdk)
|
||||
const html = renderToString(React.createElement(addon.Component, { page: 'library', path: '' }))
|
||||
const html = renderToString(React.createElement(addon.Component, { page: 'voice', path: 'library' }))
|
||||
assert.match(html, /data-testid="voice-library"/)
|
||||
})
|
||||
|
||||
test('create: the first paint of the create page has the core test id', async () => {
|
||||
const { mod, sdk } = await load()
|
||||
const addon = await mod.createAddon(sdk)
|
||||
const html = renderToString(React.createElement(addon.Component, { page: 'create', path: '' }))
|
||||
const html = renderToString(React.createElement(addon.Component, { page: 'voice', path: 'library/new' }))
|
||||
assert.match(html, /data-testid="voice-profile-create"/)
|
||||
})
|
||||
|
||||
@@ -47,12 +47,12 @@ function queuedEffects() {
|
||||
}
|
||||
const profileCalls = (calls) => calls.filter(([url]) => /^\/api\/(voice-profiles|models|operations)/.test(url))
|
||||
|
||||
for (const page of ['library', 'create']) {
|
||||
for (const [page, path] of [['library', 'library'], ['create', 'library/new']]) {
|
||||
test(`${page}: a viewer who is not an administrator sees the off notice and the page makes no call`, async () => {
|
||||
const q = queuedEffects()
|
||||
const { mod, sdk, calls } = await load({ React: q.React, viewer: { isAdmin: () => false, hasFeature: () => true } })
|
||||
const addon = await mod.createAddon(sdk)
|
||||
const html = renderToString(React.createElement(addon.Component, { page, path: '' }))
|
||||
const html = renderToString(React.createElement(addon.Component, { page: 'voice', path }))
|
||||
q.run()
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
assert.match(html, /data-testid="voice-admin-only"/)
|
||||
@@ -65,7 +65,7 @@ test('library: an administrator gets the library and the profile list is request
|
||||
const q = queuedEffects()
|
||||
const { mod, sdk, calls } = await load({ React: q.React })
|
||||
const addon = await mod.createAddon(sdk)
|
||||
const html = renderToString(React.createElement(addon.Component, { page: 'library', path: '' }))
|
||||
const html = renderToString(React.createElement(addon.Component, { page: 'voice', path: 'library' }))
|
||||
q.run()
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
assert.doesNotMatch(html, /voice-admin-only/)
|
||||
|
||||
@@ -13,7 +13,7 @@ const { renderToString } = require('react-dom/server')
|
||||
test('recognition: the first paint has the core test id and the page title', async () => {
|
||||
const { mod, sdk } = await load()
|
||||
const addon = await mod.createAddon(sdk)
|
||||
const html = renderToString(React.createElement(addon.Component, { page: 'recognition', path: '' }))
|
||||
const html = renderToString(React.createElement(addon.Component, { page: 'voice', path: 'recognition' }))
|
||||
assert.match(html, /data-testid="voices-page"/)
|
||||
assert.match(html, /Voices/)
|
||||
})
|
||||
@@ -21,7 +21,7 @@ test('recognition: the first paint has the core test id and the page title', asy
|
||||
test('recognition: a viewer without voice_recognition gets the off notice, not the workbench', async () => {
|
||||
const { mod, sdk } = await load({ viewer: { isAdmin: () => false, hasFeature: (f) => f !== 'voice_recognition' } })
|
||||
const addon = await mod.createAddon(sdk)
|
||||
const html = renderToString(React.createElement(addon.Component, { page: 'recognition', path: '' }))
|
||||
const html = renderToString(React.createElement(addon.Component, { page: 'voice', path: 'recognition' }))
|
||||
assert.match(html, /data-testid="voice-off"/)
|
||||
assert.doesNotMatch(html, /idn-bench/)
|
||||
})
|
||||
@@ -34,23 +34,23 @@ test('recognition: the model of the path reaches the model selection', async ()
|
||||
components.ModelSelector = (p) => { seen.push(p.value); return React.createElement('div') }
|
||||
const { mod, sdk } = await load({ components })
|
||||
const addon = await mod.createAddon(sdk)
|
||||
renderToString(React.createElement(addon.Component, { page: 'recognition', path: 'speechbrain' }))
|
||||
renderToString(React.createElement(addon.Component, { page: 'voice', path: 'recognition/speechbrain' }))
|
||||
assert.deepEqual(seen, ['speechbrain'])
|
||||
})
|
||||
|
||||
test('recognition: a viewer who is not an administrator still gets the workbench and no speech voices tab', async () => {
|
||||
test('recognition: a viewer who is not an administrator still gets the workbench and no Library tab', async () => {
|
||||
const { mod, sdk } = await load({ viewer: { isAdmin: () => false, hasFeature: () => true } })
|
||||
const addon = await mod.createAddon(sdk)
|
||||
const html = renderToString(React.createElement(addon.Component, { page: 'recognition', path: '' }))
|
||||
const html = renderToString(React.createElement(addon.Component, { page: 'voice', path: 'recognition' }))
|
||||
assert.match(html, /idn-bench/)
|
||||
assert.doesNotMatch(html, /Speech voices/)
|
||||
assert.doesNotMatch(html, /Library/)
|
||||
})
|
||||
|
||||
test('recognition: an administrator sees the three tabs', async () => {
|
||||
test('recognition: an administrator sees the four tabs', async () => {
|
||||
const { mod, sdk } = await load()
|
||||
const addon = await mod.createAddon(sdk)
|
||||
const html = renderToString(React.createElement(addon.Component, { page: 'recognition', path: '' }))
|
||||
for (const label of ['Speakers', 'Speech voices', 'From a recording']) assert.ok(html.includes(label), label)
|
||||
const html = renderToString(React.createElement(addon.Component, { page: 'voice', path: 'recognition' }))
|
||||
for (const label of ['Recognition', 'Library', 'Text to speech', 'From a recording']) assert.ok(html.includes(label), label)
|
||||
})
|
||||
|
||||
test('recognition: the bundle carries no face-only canvas and no host import', async () => {
|
||||
|
||||
@@ -19,7 +19,7 @@ export async function load(extra = {}) {
|
||||
const calls = []
|
||||
const controller = new AbortController()
|
||||
const sdk = createHostSDK({
|
||||
addonId: 'voice', generation: 1, signal: controller.signal, pages: ['recognition', 'library', 'create', 'speech'],
|
||||
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')])),
|
||||
@@ -50,18 +50,36 @@ test('render: an unknown page draws nothing', async () => {
|
||||
assert.equal(renderToString(React.createElement(addon.Component, { page: 'nope', path: '' })), '')
|
||||
})
|
||||
|
||||
test('render: the manifest declares the four pages, the main page and SDK 2', async () => {
|
||||
test('render: the manifest declares one page in the navigation, the older pages, the main page and SDK 2', async () => {
|
||||
const { readFileSync } = await import('node:fs')
|
||||
const m = JSON.parse(readFileSync(join(here, '../../manifest.json'), 'utf8'))
|
||||
assert.equal(m.id, 'voice')
|
||||
assert.deepEqual(m.capabilities.ui.pages, ['recognition', 'library', 'create', 'speech'])
|
||||
assert.equal(m.capabilities.ui.mainPage, 'recognition')
|
||||
assert.deepEqual(m.capabilities.ui.pages, ['voice', 'recognition', 'library', 'create', 'speech'])
|
||||
assert.deepEqual(m.capabilities.ui.navigation, ['voice'])
|
||||
assert.equal(m.capabilities.ui.mainPage, 'voice')
|
||||
assert.deepEqual(m.capabilities.ui.sdk, { min: 2, max: 2 })
|
||||
assert.deepEqual(m.capabilities.tools ?? [], [])
|
||||
})
|
||||
|
||||
test('render: a registered page is drawn with its path', async () => {
|
||||
test('render: a tab is drawn with the rest of the path and the tab strip', async () => {
|
||||
const { mod, sdk } = await load()
|
||||
const addon = await mod.createAddon(sdk, { pages: { speech: ({ path }) => React.createElement('p', null, `page:${path}`) } })
|
||||
assert.equal(renderToString(React.createElement(addon.Component, { page: 'speech', path: 'abc' })), '<p>page:abc</p>')
|
||||
const addon = await mod.createAddon(sdk, { pages: { speech: ({ path, tabs }) => React.createElement('div', null, tabs, React.createElement('p', null, `page:${path}`)) } })
|
||||
const html = renderToString(React.createElement(addon.Component, { page: 'voice', path: 'speech/abc' }))
|
||||
assert.match(html, /<p>page:abc<\/p>/)
|
||||
assert.match(html, /data-testid="voice-tabs"/)
|
||||
assert.match(html, /data-tab="speech"[^>]*aria-selected="true"/)
|
||||
})
|
||||
|
||||
test('render: no tab in the path opens the recognition tab', async () => {
|
||||
const { mod, sdk } = await load()
|
||||
const addon = await mod.createAddon(sdk)
|
||||
assert.match(renderToString(React.createElement(addon.Component, { page: 'voice', path: '' })), /data-testid="voices-page"/)
|
||||
})
|
||||
|
||||
test('render: an older page name draws nothing itself; the shell sends it to its tab', async () => {
|
||||
const { mod, sdk } = await load()
|
||||
const addon = await mod.createAddon(sdk)
|
||||
for (const page of ['recognition', 'library', 'create', 'speech']) {
|
||||
assert.equal(renderToString(React.createElement(addon.Component, { page, path: '' })), '', page)
|
||||
}
|
||||
})
|
||||
@@ -13,7 +13,7 @@ const { renderToString } = require('react-dom/server')
|
||||
test('speech: the first paint shows the title and the text box', async () => {
|
||||
const { mod, sdk } = await load()
|
||||
const addon = await mod.createAddon(sdk)
|
||||
const html = renderToString(React.createElement(addon.Component, { page: 'speech', path: '' }))
|
||||
const html = renderToString(React.createElement(addon.Component, { page: 'voice', path: 'speech' }))
|
||||
assert.match(html, /data-testid="voice-speech"/)
|
||||
assert.match(html, /<textarea/)
|
||||
})
|
||||
|
||||
@@ -254,8 +254,8 @@ test.beforeAll(async () => {
|
||||
})
|
||||
|
||||
const voiceApp = () => app({
|
||||
id: 'voice', name: 'voice', title: 'Voice', icon: undefined, generation: 1, version: '0.1.0', mainPage: '/app/addons/voice/recognition',
|
||||
ui: { entry: `${VOICE_BASE}/ui/voice.js`, css: [`${VOICE_BASE}/ui/voice.css`], sdk: { min: 2, max: 2 }, pages: ['recognition', 'library', 'create', 'speech'], navigation: [], chatActions: [] },
|
||||
id: 'voice', name: 'voice', title: 'Voice', icon: undefined, generation: 1, version: '0.1.0', mainPage: '/app/addons/voice/voice',
|
||||
ui: { entry: `${VOICE_BASE}/ui/voice.js`, css: [`${VOICE_BASE}/ui/voice.css`], sdk: { min: 2, max: 2 }, pages: ['voice', 'recognition', 'library', 'create', 'speech'], navigation: ['voice'], chatActions: [] },
|
||||
})
|
||||
|
||||
async function serveVoice(page) {
|
||||
@@ -279,14 +279,26 @@ async function expectVoicesPage(page) {
|
||||
}
|
||||
}
|
||||
|
||||
// The tabs of the one Voice page. An administrator sees all four.
|
||||
async function expectVoiceTabs(page, active) {
|
||||
const tabs = page.getByTestId('voice-tabs')
|
||||
await expect(tabs).toBeVisible()
|
||||
for (const name of [/^Recognition/, /^Library/, /^Text to speech$/, /^From a recording$/]) {
|
||||
await expect(tabs.getByRole('tab', { name })).toBeVisible()
|
||||
}
|
||||
await expect(tabs.locator('[aria-selected="true"]')).toHaveAttribute('data-tab', active)
|
||||
}
|
||||
|
||||
test.describe('Add-on navigation: the Voice recognition page', () => {
|
||||
test('the Voice add-on draws the same page, test ids and tabs as the core page', async ({ page }) => {
|
||||
test('the Voice add-on draws the recognition tab with the core test ids', async ({ page }) => {
|
||||
await setup(page, { apps: [voiceApp()] })
|
||||
await asAdmin(page)
|
||||
await serveVoice(page)
|
||||
await mockIdentity(page)
|
||||
await page.goto('/app/addons/voice/recognition')
|
||||
await expectVoicesPage(page)
|
||||
await page.goto('/app/addons/voice/voice/recognition')
|
||||
await expect(page.getByTestId('voices-page')).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Voices', level: 1 })).toBeVisible()
|
||||
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')
|
||||
})
|
||||
@@ -306,7 +318,7 @@ test.describe('Add-on navigation: the Voice recognition page', () => {
|
||||
await serveVoice(page)
|
||||
await mockIdentity(page)
|
||||
await page.goto('/app/voice/org%2Fname')
|
||||
await expect(page).toHaveURL(/\/app\/addons\/voice\/recognition\?model=org%2Fname$/)
|
||||
await expect(page).toHaveURL(/\/app\/addons\/voice\/voice\/recognition\?model=org%2Fname$/)
|
||||
await expect(page.getByTestId('voices-page')).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -315,11 +327,78 @@ test.describe('Add-on navigation: the Voice recognition page', () => {
|
||||
// An administrator holds every feature, so the viewer is a member.
|
||||
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(MEMBER, { addons: true, voice_recognition: false }) }))
|
||||
await serveVoice(page)
|
||||
await page.goto('/app/addons/voice/recognition')
|
||||
await page.goto('/app/addons/voice/voice/recognition')
|
||||
await expect(page.getByTestId('voice-off')).toBeVisible()
|
||||
await expect(page.getByTestId('voices-page')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('the sections are tabs of one page and each tab keeps its address', async ({ page }) => {
|
||||
await setup(page, { apps: [voiceApp()] })
|
||||
await asAdmin(page)
|
||||
await serveVoice(page)
|
||||
await mockIdentity(page)
|
||||
await page.goto('/app/addons/voice/voice')
|
||||
// No tab in the address opens the default tab.
|
||||
await expect(page.getByTestId('voices-page')).toBeVisible()
|
||||
await expectVoiceTabs(page, 'recognition')
|
||||
|
||||
const tabs = page.getByTestId('voice-tabs')
|
||||
await tabs.getByRole('tab', { name: /^Library/ }).click()
|
||||
await expect(page).toHaveURL(/\/app\/addons\/voice\/voice\/library$/)
|
||||
await expect(page.getByTestId('voice-library')).toBeVisible()
|
||||
await expectVoiceTabs(page, 'library')
|
||||
|
||||
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 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 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')
|
||||
})
|
||||
|
||||
test('a viewer who is not an administrator gets no Library tab', async ({ page }) => {
|
||||
await setup(page, { apps: [voiceApp()] })
|
||||
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(MEMBER, { addons: true, voice_recognition: true }) }))
|
||||
await serveVoice(page)
|
||||
await mockIdentity(page)
|
||||
await page.goto('/app/addons/voice/voice/recognition')
|
||||
const tabs = page.getByTestId('voice-tabs')
|
||||
await expect(tabs.getByRole('tab', { name: /^Recognition/ })).toBeVisible()
|
||||
await expect(tabs.getByRole('tab', { name: /^Text to speech$/ })).toBeVisible()
|
||||
await expect(tabs.getByRole('tab', { name: /^Library/ })).toHaveCount(0)
|
||||
await page.goto('/app/addons/voice/voice/library')
|
||||
await expect(page.getByTestId('voice-admin-only')).toBeVisible()
|
||||
await expect(page.getByTestId('voice-library')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('the older page addresses of the add-on open the matching tab', async ({ page }) => {
|
||||
await setup(page, { apps: [voiceApp()] })
|
||||
await asAdmin(page)
|
||||
await serveVoice(page)
|
||||
await mockIdentity(page)
|
||||
const cases = [
|
||||
['/app/addons/voice/recognition', /\/voice\/voice\/recognition$/, 'recognition'],
|
||||
['/app/addons/voice/recognition?model=wespeaker-resnet34', /\/voice\/voice\/recognition\?model=wespeaker-resnet34$/, 'recognition'],
|
||||
['/app/addons/voice/recognition?tab=recording', /\/voice\/voice\/recording$/, 'recording'],
|
||||
['/app/addons/voice/library?selected=prof-1', /\/voice\/voice\/library\?selected=prof-1$/, 'library'],
|
||||
['/app/addons/voice/create', /\/voice\/voice\/library\/new$/, 'library'],
|
||||
['/app/addons/voice/speech?voice=prof-1', /\/voice\/voice\/speech\?voice=prof-1$/, 'speech'],
|
||||
]
|
||||
for (const [from, to, active] of cases) {
|
||||
await page.goto(from)
|
||||
await expect(page, from).toHaveURL(to)
|
||||
await expectVoiceTabs(page, active)
|
||||
}
|
||||
})
|
||||
|
||||
test('the Build hub has no Voices tab', async ({ page }) => {
|
||||
await setup(page, { apps: [] })
|
||||
await asAdmin(page)
|
||||
@@ -334,7 +413,7 @@ test.describe('Add-on navigation: the Voice recognition page', () => {
|
||||
await asAdmin(page)
|
||||
await serveVoice(page)
|
||||
const calls = await mockIdentity(page, { stored: people, identify: { matches: [match('v-marta', 'Marta Rossi', 0.18), match('v-jonas', 'Jonas Weber', 0.41)] } })
|
||||
await page.goto('/app/addons/voice/recognition')
|
||||
await page.goto('/app/addons/voice/voice/recognition')
|
||||
await expect(page.getByTestId('voices-page')).toBeVisible()
|
||||
|
||||
await page.locator('#voice-probe-audio-file').setInputFiles(clipFile())
|
||||
|
||||
@@ -68,7 +68,8 @@ test('the same-path routes need no redirect', () => {
|
||||
test('moved biometric routes are wrapped in MovedPage in the router', () => {
|
||||
// Studio and Talk have no core page to fall back to; they redirect (see above).
|
||||
for (const r of MIGRATION_ROUTES.filter(x => x.kind === 'addon-page' && !['/app/studio', '/app/talk'].includes(x.from))) {
|
||||
const [, , , addon, page] = r.to.split('/')
|
||||
const [, , , addon, ...rest] = r.to.split('/')
|
||||
const page = rest.join('/')
|
||||
assert.ok(router.includes(`<MovedPage addon="${addon}" page="${page}">`) || router.includes(`<MovedPage addon="${addon}" page="${page}" withModel>`), `router.jsx does not wrap ${r.from}`)
|
||||
}
|
||||
})
|
||||
@@ -115,3 +116,10 @@ test('the Build overview keeps the fine-tune and quantize cards, linked to the o
|
||||
const { buildOverviewTools } = await import('../../src/components/hub/hubConfig.js')
|
||||
assert.deepEqual(buildOverviewTools.map(t => [t.id, t.items[0].path]), [['fine-tune', '/app/fine-tune'], ['quantize', '/app/quantize']])
|
||||
})
|
||||
|
||||
test('the voice routes open the tabs of the one Voice page', () => {
|
||||
const to = (from) => MIGRATION_ROUTES.find(r => r.from === from)?.to
|
||||
assert.equal(to('/app/voice'), '/app/addons/voice/voice/recognition')
|
||||
assert.equal(to('/app/voice-library'), '/app/addons/voice/voice/library')
|
||||
assert.equal(to('/app/voice-library/new'), '/app/addons/voice/voice/library/new')
|
||||
})
|
||||
Reference in new issue
Block a user