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:
Ettore Di Giacinto committed 2026-10-20 23:18:09 +00:00
1 parent ee1c02929e
commit c1941b5faf
6 files changed
+136 -31

No files matched your search

+5 -5
View File
@@ -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/)
+9 -9
View File
@@ -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 () => {
+25 -7
View File
@@ -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)
}
})
+1 -1
View File
@@ -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')
})