mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-02 10:34:46 -05:00
The pages are add-ons now, so a spec that draws one installs the real built bundle first: the apps list names it and its module and style sheet are served from the build output, the way the add-on owner serves them. studio-addon.js holds that helper, and the Studio and Talk fixtures call it. Update the specs for the new addresses (/app/addons/studio/studio, the model as ?model=), the session permission the add-on route needs, the legacy redirects, the Home /studio command that appears only while the add-on is installed, and the sidebar without a Create group. Add specs for the add-ons themselves: every tab draws with no script error, the shell titles the page once, the old addresses say so when the add-on is not installed, a button of the page reaches a core page with the way back, and the drawer lists both only after they are installed. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
531 lines
28 KiB
JavaScript
531 lines
28 KiB
JavaScript
import { test, expect } from './coverage-fixtures.js'
|
|
import {
|
|
FACE_KEY, VOICE_KEY, clipFile, faces, match, mockIdentity, people, photoFile,
|
|
} from './identity-fixtures.js'
|
|
|
|
const marta = match('v-marta', 'Marta Rossi', 0.18)
|
|
const jonas = match('v-jonas', 'Jonas Weber', 0.41)
|
|
const ilse = match('v-ilse', 'Ilse Janssen', 0.52)
|
|
const closeSet = { matches: [marta, jonas, ilse] }
|
|
const farSet = { matches: [match('v-jonas', 'Jonas Weber', 0.31), match('v-ilse', 'Ilse Janssen', 0.47), match('v-marta', 'Marta Rossi', 0.55)] }
|
|
|
|
async function addProbe(page, name = 'board-sync-clip.wav', seconds = 8) {
|
|
await page.locator('#voice-probe-audio-file').setInputFiles(clipFile(name, seconds))
|
|
await expect(page.getByTestId('voice-probe-clip')).toHaveAttribute('data-state', 'filled')
|
|
}
|
|
|
|
test.describe('Voices: Speakers', () => {
|
|
test('empty: says nobody is enrolled and that the list lives in the browser', async ({ page }) => {
|
|
await mockIdentity(page)
|
|
await page.goto('/app/voice')
|
|
await expect(page.getByRole('heading', { name: 'Voices', level: 1 })).toBeVisible()
|
|
await expect(page.getByTestId('known-empty')).toContainText('Nobody enrolled in this browser yet')
|
|
await expect(page.getByTestId('registry-note')).toContainText('kept in this browser')
|
|
await expect(page.getByTestId('registry-note')).toContainText('no list call')
|
|
await expect(page.getByTestId('identify-go')).toBeDisabled()
|
|
// The Build bar has no Voices tab and no second row.
|
|
await expect(page.locator('.dk-hubtabs [data-hub-tab="voices"]')).toHaveCount(0)
|
|
await expect(page.locator('.hub-subnav')).toHaveCount(0)
|
|
})
|
|
|
|
test('who is this: a match is explained in words, on a scale, with the real numbers', async ({ page }) => {
|
|
const calls = await mockIdentity(page, { stored: people, identify: closeSet })
|
|
await page.goto('/app/voice')
|
|
await addProbe(page)
|
|
await expect(page.getByText('good length')).toBeVisible()
|
|
await page.getByTestId('identify-go').click()
|
|
const verdict = page.getByTestId('verdict')
|
|
await expect(verdict).toContainText('Probably Marta Rossi')
|
|
await expect(verdict).toContainText('Likely match')
|
|
await expect(verdict).toContainText('0.18 away')
|
|
await expect(verdict).toContainText('under 0.25')
|
|
await expect(page.getByTestId('distance-scale')).toBeVisible()
|
|
await expect(page.getByTestId('rank-row')).toHaveCount(3)
|
|
await expect(page.getByTestId('rank-row').first()).toContainText('within cut-off')
|
|
await expect(page.getByTestId('cutoff-value')).toHaveText('0.25')
|
|
// The request carries the model, the clip, a top_k that covers everyone, and the cut-off.
|
|
expect(calls.identify).toHaveLength(1)
|
|
expect(calls.identify[0]).toMatchObject({ model: 'wespeaker-resnet34', threshold: 0.25 })
|
|
expect(calls.identify[0].audio).toMatch(/^data:audio\//)
|
|
expect(calls.identify[0].top_k).toBeGreaterThanOrEqual(25)
|
|
await expect(page.getByTestId('known-row-v-marta')).toContainText('best match')
|
|
})
|
|
|
|
test('the cut-off slider re-reads the answer without another call', async ({ page }) => {
|
|
const calls = await mockIdentity(page, { stored: people, identify: closeSet })
|
|
await page.goto('/app/voice')
|
|
await addProbe(page)
|
|
await page.getByTestId('identify-go').click()
|
|
await expect(page.getByTestId('verdict')).toContainText('Probably Marta Rossi')
|
|
const slider = page.locator('#voice-find-cutoff')
|
|
await slider.fill('0.1')
|
|
await expect(page.getByTestId('cutoff-value')).toHaveText('0.10')
|
|
await expect(page.getByTestId('verdict')).toContainText('Nobody enrolled sounds like this')
|
|
await expect(page.getByRole('button', { name: 'Reset to 0.25' })).toBeVisible()
|
|
await page.getByRole('button', { name: 'Reset to 0.25' }).click()
|
|
await expect(page.getByTestId('verdict')).toContainText('Probably Marta Rossi')
|
|
expect(calls.identify).toHaveLength(1)
|
|
})
|
|
|
|
test('no match: names the closest person and says why it may be wrong', async ({ page }) => {
|
|
await mockIdentity(page, { stored: people, identify: farSet })
|
|
await page.goto('/app/voice')
|
|
await addProbe(page, 'hallway-recording.wav', 2.4)
|
|
await expect(page.getByText('short', { exact: true })).toBeVisible()
|
|
await page.getByTestId('identify-go').click()
|
|
const verdict = page.getByTestId('verdict')
|
|
await expect(verdict).toContainText('Nobody enrolled sounds like this')
|
|
await expect(verdict).toContainText('closest was Jonas Weber at 0.31')
|
|
await expect(page.getByRole('button', { name: 'Enrol this voice' })).toBeVisible()
|
|
await page.getByText('Why this might be wrong').click()
|
|
await expect(page.getByTestId('why-list')).toContainText('not a security check')
|
|
await expect(page.getByTestId('why-list')).toContainText('short: 2.4 s')
|
|
})
|
|
|
|
test('working: a progress bar and a disabled button while the call is out', async ({ page }) => {
|
|
await mockIdentity(page, { stored: people, identify: closeSet, delay: 1500 })
|
|
await page.goto('/app/voice')
|
|
await addProbe(page)
|
|
await page.getByTestId('identify-go').click()
|
|
await expect(page.getByTestId('identify-working')).toBeVisible()
|
|
await expect(page.getByTestId('identify-working')).toHaveAttribute('aria-busy', 'true')
|
|
await expect(page.getByTestId('identify-go')).toBeDisabled()
|
|
await expect(page.getByTestId('verdict')).toBeVisible()
|
|
})
|
|
|
|
test('failed: plain words, the server message, a retry and a trace link', async ({ page }) => {
|
|
const calls = await mockIdentity(page, { stored: people, identify: { status: 500, body: { error: { message: 'backend crashed' } } } })
|
|
await page.goto('/app/voice')
|
|
await addProbe(page)
|
|
await page.getByTestId('identify-go').click()
|
|
const err = page.getByTestId('identity-error')
|
|
await expect(err).toContainText('The call failed')
|
|
await expect(err).toContainText('backend crashed')
|
|
await expect(err.getByRole('link', { name: 'Open traces' })).toHaveAttribute('href', /\/app\/traces/)
|
|
await err.getByRole('button', { name: 'Try again' }).click()
|
|
await expect.poll(() => calls.identify.length).toBe(2)
|
|
})
|
|
|
|
test('failed: a missing model is named as such', async ({ page }) => {
|
|
await mockIdentity(page, { stored: people, identify: { status: 404, body: { error: { message: 'model not found' } } } })
|
|
await page.goto('/app/voice')
|
|
await addProbe(page)
|
|
await page.getByTestId('identify-go').click()
|
|
await expect(page.getByTestId('identity-error')).toContainText('cannot find this model')
|
|
})
|
|
|
|
test('same person: uses the model cut-off from the answer and shows the pair on the scale', async ({ page }) => {
|
|
const calls = await mockIdentity(page, { stored: people, verify: { verified: false, distance: 0.31, threshold: 0.28, confidence: 0, model: 'x' } })
|
|
await page.goto('/app/voice')
|
|
await page.getByRole('tab', { name: 'Same person?' }).click()
|
|
await page.locator('#voice-a-audio-file').setInputFiles(clipFile('a.wav'))
|
|
await page.locator('#voice-b-audio-file').setInputFiles(clipFile('b.wav', 6))
|
|
await page.getByTestId('compare-go').click()
|
|
const verdict = page.getByTestId('verdict')
|
|
await expect(verdict).toContainText('Probably different people')
|
|
await expect(verdict).toContainText('0.31 apart')
|
|
await expect(verdict).toContainText('under 0.28')
|
|
expect(calls.verify[0]).toMatchObject({ model: 'wespeaker-resnet34' })
|
|
expect(calls.verify[0]).not.toHaveProperty('threshold')
|
|
await expect(page.getByTestId('cutoff-value')).toHaveText('0.28')
|
|
await page.locator('#voice-pair-cutoff').fill('0.4')
|
|
await expect(verdict).toContainText('Probably the same person')
|
|
expect(calls.verify).toHaveLength(1)
|
|
})
|
|
|
|
test('mic blocked: says so and offers a file', async ({ page }) => {
|
|
await page.addInitScript(() => {
|
|
Object.defineProperty(navigator, 'mediaDevices', {
|
|
value: { getUserMedia: () => Promise.reject(Object.assign(new Error('Permission denied'), { name: 'NotAllowedError' })) },
|
|
configurable: true,
|
|
})
|
|
})
|
|
await mockIdentity(page, { stored: people })
|
|
await page.goto('/app/voice')
|
|
await page.getByTestId('voice-probe-clip').getByRole('button', { name: 'Record' }).click()
|
|
await expect(page.getByTestId('clip-notice')).toContainText('microphone is blocked')
|
|
await expect(page.getByTestId('voice-probe-clip').getByRole('button', { name: 'Choose a file' })).toBeVisible()
|
|
await expect(page.getByTestId('voice-probe-clip').getByRole('button', { name: 'Record' })).toHaveCount(0)
|
|
})
|
|
|
|
test('mic allowed: shows the state when the browser reports it', async ({ page }) => {
|
|
await page.addInitScript(() => {
|
|
const status = { state: 'granted', onchange: null }
|
|
Object.defineProperty(navigator, 'permissions', { value: { query: () => Promise.resolve(status) }, configurable: true })
|
|
Object.defineProperty(navigator, 'mediaDevices', { value: { getUserMedia: () => Promise.reject(new Error('unused')) }, configurable: true })
|
|
})
|
|
await mockIdentity(page, { stored: people })
|
|
await page.goto('/app/voice')
|
|
await expect(page.getByTestId('mic-allowed')).toContainText('Microphone allowed')
|
|
})
|
|
|
|
test('mic on an insecure origin is explained', async ({ page }) => {
|
|
await page.addInitScript(() => {
|
|
Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true })
|
|
Object.defineProperty(navigator, 'mediaDevices', { value: undefined, configurable: true })
|
|
})
|
|
await mockIdentity(page, { stored: people })
|
|
await page.goto('/app/voice')
|
|
await expect(page.getByTestId('clip-notice')).toContainText('https or on localhost')
|
|
})
|
|
})
|
|
|
|
test.describe('Voices: the registry tells the truth', () => {
|
|
const priya = { id: 'v-priya', name: 'Priya Nair', labels: { team: 'platform' }, registeredAt: new Date(Date.now() - 86400000).toISOString() }
|
|
|
|
test('a person the search did not return is marked, and a stranger is listed', async ({ page }) => {
|
|
await mockIdentity(page, {
|
|
stored: [...people, priya],
|
|
identify: { matches: [marta, jonas, ilse, match('v-other', 'Omar Haddad', 0.6)] },
|
|
})
|
|
await page.goto('/app/voice')
|
|
// Before a search nothing is claimed.
|
|
await expect(page.getByTestId('not-on-server')).toHaveCount(0)
|
|
await addProbe(page)
|
|
await page.getByTestId('identify-go').click()
|
|
await expect(page.getByTestId('verdict')).toBeVisible()
|
|
await expect(page.getByTestId('known-row-v-priya').getByTestId('not-on-server')).toContainText('not on the server')
|
|
await expect(page.getByTestId('known-row-v-priya')).toContainText('may have restarted')
|
|
await expect(page.getByTestId('known-row-v-marta').getByTestId('not-on-server')).toHaveCount(0)
|
|
await expect(page.getByTestId('known-row-v-other').getByTestId('not-in-browser')).toBeVisible()
|
|
await expect(page.getByTestId('registry-note')).toContainText('The last search checked it')
|
|
})
|
|
|
|
test('the server returning nobody is never read as proof when the search was cut short', async ({ page }) => {
|
|
// 25 asked, 25 returned: the search may have missed people, so nothing is marked.
|
|
const many = Array.from({ length: 25 }, (_, i) => match(`x-${i}`, `Person ${i}`, 0.3 + i / 100))
|
|
await mockIdentity(page, { stored: [...people, priya], identify: { matches: many } })
|
|
await page.goto('/app/voice')
|
|
await addProbe(page)
|
|
await page.getByTestId('identify-go').click()
|
|
await expect(page.getByTestId('verdict')).toBeVisible()
|
|
await expect(page.getByTestId('not-on-server')).toHaveCount(0)
|
|
})
|
|
|
|
test('an empty answer after a restart says the voices are gone', async ({ page }) => {
|
|
await mockIdentity(page, { stored: people, identify: { matches: [] } })
|
|
await page.goto('/app/voice')
|
|
await addProbe(page)
|
|
await page.getByTestId('identify-go').click()
|
|
await expect(page.getByTestId('verdict')).toContainText('The server returned no speakers')
|
|
await expect(page.getByTestId('verdict')).toContainText('enrol them again')
|
|
await expect(page.getByTestId('not-on-server')).toHaveCount(3)
|
|
})
|
|
|
|
test('what is stored, and where, is stated', async ({ page }) => {
|
|
await mockIdentity(page, { stored: people })
|
|
await page.goto('/app/voice')
|
|
await page.getByTestId('stored-note').getByText('What is stored, and where').click()
|
|
const note = page.getByTestId('stored-note')
|
|
await expect(note).toContainText('lost when it restarts')
|
|
await expect(note).toContainText('The recording is not kept')
|
|
await expect(note).toContainText('only if you tick Keep a copy')
|
|
})
|
|
})
|
|
|
|
test.describe('Voices: enrol sheet', () => {
|
|
test('lists what is still needed, then registers and saves only the metadata', async ({ page }) => {
|
|
const calls = await mockIdentity(page, { stored: [] })
|
|
await page.goto('/app/voice')
|
|
await page.getByTestId('enrol-open').click()
|
|
const sheet = page.getByTestId('enrol-sheet')
|
|
await expect(sheet).toBeVisible()
|
|
const submit = sheet.getByRole('button', { name: /^Enrol/ })
|
|
await expect(submit).toBeDisabled()
|
|
await expect(page.getByTestId('enrol-needs')).toContainText('a recording, a name, permission')
|
|
await page.locator('#voice-enrol-audio-file').setInputFiles(clipFile('x.wav', 5))
|
|
await page.locator('#voice-enrol-name').fill('Marta Rossi')
|
|
await page.locator('#voice-enrol-labels').fill('team: platform')
|
|
await expect(page.getByTestId('enrol-needs')).toContainText('permission')
|
|
await sheet.getByText('This person agreed').click()
|
|
await expect(page.getByTestId('enrol-needs')).toHaveText('Ready to enrol.')
|
|
await submit.click()
|
|
await expect(sheet).toHaveCount(0)
|
|
expect(calls.register[0]).toMatchObject({ model: 'wespeaker-resnet34', name: 'Marta Rossi', labels: { team: 'platform' } })
|
|
expect(calls.register[0].audio).toMatch(/^data:audio\//)
|
|
await expect(page.getByTestId('known-list')).toContainText('Marta Rossi')
|
|
const stored = await page.evaluate((k) => JSON.parse(localStorage.getItem(k)), VOICE_KEY)
|
|
expect(stored).toHaveLength(1)
|
|
expect(stored[0]).toMatchObject({ name: 'Marta Rossi', labels: { team: 'platform' } })
|
|
// The copy of the recording is opt-in; it was not ticked.
|
|
expect(stored[0]).not.toHaveProperty('sampleUrl')
|
|
})
|
|
|
|
test('keeping a copy is opt-in and stays in the browser', async ({ page }) => {
|
|
const calls = await mockIdentity(page, { stored: [] })
|
|
await page.goto('/app/voice')
|
|
await page.getByTestId('enrol-open').click()
|
|
await page.locator('#voice-enrol-audio-file').setInputFiles(clipFile('x.wav', 2))
|
|
await page.locator('#voice-enrol-name').fill('Ada')
|
|
const sheet = page.getByTestId('enrol-sheet')
|
|
await sheet.getByText('This person agreed').click()
|
|
await sheet.getByText('Keep a copy in this browser').click()
|
|
await sheet.getByRole('button', { name: /^Enrol/ }).click()
|
|
await expect(sheet).toHaveCount(0)
|
|
const stored = await page.evaluate((k) => JSON.parse(localStorage.getItem(k)), VOICE_KEY)
|
|
expect(stored[0].sampleUrl).toMatch(/^data:audio\//)
|
|
expect(JSON.stringify(calls.register[0])).not.toContain('sampleUrl')
|
|
})
|
|
|
|
test('can also keep the recording as a speech voice (admin)', async ({ page }) => {
|
|
const calls = await mockIdentity(page, { stored: [] })
|
|
await page.goto('/app/voice')
|
|
await page.getByTestId('enrol-open').click()
|
|
const sheet = page.getByTestId('enrol-sheet')
|
|
await page.locator('#voice-enrol-audio-file').setInputFiles(clipFile('x.wav', 3))
|
|
await page.locator('#voice-enrol-name').fill('Marta Rossi')
|
|
await sheet.getByText('Also keep it as a speech voice').click()
|
|
await expect(page.getByTestId('enrol-needs')).toContainText('a transcript')
|
|
await page.locator('#voice-enrol-transcript').fill('The words spoken.')
|
|
await sheet.getByText('This person agreed').click()
|
|
await sheet.getByRole('button', { name: /^Enrol/ }).click()
|
|
await expect(sheet).toHaveCount(0)
|
|
expect(calls.register).toHaveLength(1)
|
|
expect(calls.profile).toHaveLength(1)
|
|
})
|
|
|
|
test('a failed enrol keeps the sheet and says why', async ({ page }) => {
|
|
await mockIdentity(page, { stored: [] })
|
|
await page.route('**/v1/voice/register', route => route.fulfill({ status: 500, json: { error: { message: 'store unavailable' } } }))
|
|
await page.goto('/app/voice')
|
|
await page.getByTestId('enrol-open').click()
|
|
await page.locator('#voice-enrol-audio-file').setInputFiles(clipFile('x.wav', 2))
|
|
await page.locator('#voice-enrol-name').fill('Ada')
|
|
const sheet = page.getByTestId('enrol-sheet')
|
|
await sheet.getByText('This person agreed').click()
|
|
await sheet.getByRole('button', { name: /^Enrol/ }).click()
|
|
await expect(sheet.getByTestId('identity-error')).toContainText('store unavailable')
|
|
await expect(page.locator('#voice-enrol-name')).toHaveValue('Ada')
|
|
})
|
|
|
|
test('Escape closes the sheet', async ({ page }) => {
|
|
await mockIdentity(page, { stored: people })
|
|
await page.goto('/app/voice')
|
|
await page.getByTestId('enrol-open').click()
|
|
await expect(page.getByTestId('enrol-sheet')).toBeVisible()
|
|
await page.keyboard.press('Escape')
|
|
await expect(page.getByTestId('enrol-sheet')).toHaveCount(0)
|
|
})
|
|
})
|
|
|
|
test.describe('Voices: delete with undo', () => {
|
|
test('nothing is sent before the window ends, and Undo sends nothing', async ({ page }) => {
|
|
await page.clock.install()
|
|
const calls = await mockIdentity(page, { stored: people })
|
|
await page.goto('/app/voice')
|
|
await page.getByRole('button', { name: 'Remove Jonas Weber' }).click()
|
|
await expect(page.getByTestId('forget-undo-toast')).toContainText('Jonas Weber will be removed')
|
|
await expect(page.getByTestId('known-row-v-jonas')).toContainText('removing')
|
|
await page.clock.runFor(9000)
|
|
expect(calls.forget).toHaveLength(0)
|
|
await page.getByTestId('forget-undo-toast').getByRole('button', { name: 'Undo' }).click()
|
|
await page.clock.runFor(20000)
|
|
expect(calls.forget).toHaveLength(0)
|
|
await expect(page.getByTestId('known-row-v-jonas')).toBeVisible()
|
|
await expect(page.getByTestId('known-row-v-jonas')).not.toContainText('removing')
|
|
})
|
|
|
|
test('after ten seconds the forget call goes out and the row is gone', async ({ page }) => {
|
|
await page.clock.install()
|
|
const calls = await mockIdentity(page, { stored: people })
|
|
await page.goto('/app/voice')
|
|
await page.getByRole('button', { name: 'Remove Jonas Weber' }).click()
|
|
await page.clock.runFor(9500)
|
|
expect(calls.forget).toHaveLength(0)
|
|
await page.clock.runFor(1500)
|
|
await expect.poll(() => calls.forget.length).toBe(1)
|
|
expect(calls.forget[0]).toEqual({ id: 'v-jonas' })
|
|
await expect(page.getByTestId('known-row-v-jonas')).toHaveCount(0)
|
|
const stored = await page.evaluate((k) => JSON.parse(localStorage.getItem(k)).map(x => x.id), VOICE_KEY)
|
|
expect(stored).toEqual(['v-marta', 'v-ilse'])
|
|
})
|
|
|
|
test('a 404 from the server still removes the row', async ({ page }) => {
|
|
await page.clock.install()
|
|
const calls = await mockIdentity(page, { stored: people, forgetStatus: 404 })
|
|
await page.goto('/app/voice')
|
|
await page.getByRole('button', { name: 'Remove Ilse Janssen' }).click()
|
|
await page.clock.runFor(10500)
|
|
await expect.poll(() => calls.forget.length).toBe(1)
|
|
await expect(page.getByTestId('known-row-v-ilse')).toHaveCount(0)
|
|
})
|
|
})
|
|
|
|
test.describe('Voices: states of the page', () => {
|
|
test('disabled: no speaker model says what turns it on and offers installs', async ({ page }) => {
|
|
await mockIdentity(page, {
|
|
voiceModels: [],
|
|
stored: people,
|
|
gallery: [{ id: 'localai@wespeaker-resnet34', name: 'wespeaker-resnet34', backend: 'wespeaker', installed: false }],
|
|
})
|
|
let installed = null
|
|
await page.route('**/api/models/install/**', route => { installed = decodeURIComponent(route.request().url().split('/').pop()); return route.fulfill({ json: { jobID: 'j' } }) })
|
|
await page.goto('/app/voice')
|
|
const needed = page.getByTestId('model-needed')
|
|
await expect(needed).toContainText('Voices need a speaker model')
|
|
await expect(needed).toContainText('Not enabled on this server')
|
|
await expect(needed.getByRole('button', { name: 'Install' })).toBeVisible()
|
|
await needed.getByText('What turns this on').click()
|
|
await expect(needed).toContainText('Voice recognition permission')
|
|
await needed.getByRole('button', { name: 'Install' }).click()
|
|
await expect.poll(() => installed).toBe('localai@wespeaker-resnet34')
|
|
// The remembered people stay manageable without a model.
|
|
await expect(page.getByTestId('known-list')).toContainText('Marta Rossi')
|
|
await expect(page.getByTestId('enrol-open')).toBeDisabled()
|
|
})
|
|
|
|
test('a user without the permission sees what is off, not a redirect', async ({ page }) => {
|
|
await mockIdentity(page)
|
|
await page.route('**/api/auth/status', route => route.fulfill({ json: { authEnabled: true, user: { id: 'u', role: 'user', permissions: {} } } }))
|
|
await page.goto('/app/voice')
|
|
await expect(page.getByTestId('feature-off')).toContainText('Voice recognition is off for your account')
|
|
await expect(page.getByTestId('feature-off')).toContainText('Voice recognition permission')
|
|
await page.goto('/app/face')
|
|
await expect(page.getByTestId('feature-off')).toContainText('Face recognition is off for your account')
|
|
})
|
|
|
|
test('From a recording links to the diarization workspace', async ({ page }) => {
|
|
await mockIdentity(page, { stored: people })
|
|
await page.goto('/app/voice?tab=recording')
|
|
await expect(page.getByTestId('recording-tab')).toContainText('Name the speakers in a recording')
|
|
await page.getByRole('link', { name: /Open the diarization workspace/ }).click()
|
|
await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/diarization/)
|
|
})
|
|
|
|
test('the route /app/voice/:model keeps working', async ({ page }) => {
|
|
await mockIdentity(page, { stored: people })
|
|
await page.goto('/app/voice/wespeaker-resnet34')
|
|
await expect(page.getByRole('heading', { name: 'Voices', level: 1 })).toBeVisible()
|
|
await expect(page.getByTestId('known-list')).toBeVisible()
|
|
})
|
|
|
|
test('phone: no sideways scroll with a result open, and the table stays inside the screen', async ({ page }) => {
|
|
await page.setViewportSize({ width: 390, height: 844 })
|
|
await mockIdentity(page, { stored: [...people, { id: 'v-priya', name: 'Priya Nair', labels: {}, registeredAt: new Date().toISOString() }], identify: closeSet })
|
|
await page.goto('/app/voice')
|
|
await addProbe(page)
|
|
await page.getByTestId('identify-go').click()
|
|
await expect(page.getByTestId('verdict')).toBeVisible()
|
|
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)
|
|
expect(overflow).toBeLessThanOrEqual(0)
|
|
const box = await page.getByTestId('distance-scale').boundingBox()
|
|
expect(box.x + box.width).toBeLessThanOrEqual(390)
|
|
await page.getByTestId('enrol-open').click()
|
|
const sheet = await page.getByTestId('enrol-sheet').boundingBox()
|
|
expect(sheet.width).toBeLessThanOrEqual(390)
|
|
expect(sheet.x).toBeGreaterThanOrEqual(0)
|
|
})
|
|
|
|
test('reduced motion: the working bar and recording pulse do not animate', async ({ page }) => {
|
|
await page.emulateMedia({ reducedMotion: 'reduce' })
|
|
await mockIdentity(page, { stored: people, identify: closeSet, delay: 2500 })
|
|
await page.goto('/app/voice')
|
|
await addProbe(page)
|
|
await page.getByTestId('identify-go').click()
|
|
const bar = page.getByTestId('identify-working').locator('.dk-progress-bar')
|
|
await expect(bar).toBeVisible()
|
|
const name = await bar.evaluate(el => getComputedStyle(el).animationName)
|
|
expect(name).toBe('none')
|
|
})
|
|
|
|
test('no left rail marks on rows, cards or the verdict', async ({ page }) => {
|
|
await mockIdentity(page, { stored: people, identify: closeSet })
|
|
await page.goto('/app/voice')
|
|
await addProbe(page)
|
|
await page.getByTestId('identify-go').click()
|
|
await expect(page.getByTestId('verdict')).toBeVisible()
|
|
const widths = await page.evaluate(() => [...document.querySelectorAll('.idn-page *')]
|
|
.map(el => getComputedStyle(el))
|
|
.filter(cs => parseFloat(cs.borderLeftWidth) >= 3 && cs.borderLeftStyle !== 'none' && parseFloat(cs.borderTopWidth) === 0)
|
|
.length)
|
|
expect(widths).toBe(0)
|
|
})
|
|
})
|
|
|
|
test.describe('Faces', () => {
|
|
const anna = match('f-anna', 'Anna Kowalski', 0.21)
|
|
const tomas = match('f-tomas', 'Tomas Ribeiro', 0.52)
|
|
|
|
test('who is this: a match, with the faceprint worded as such', async ({ page }) => {
|
|
const calls = await mockIdentity(page, { storedFaces: faces, faceIdentify: { matches: [anna, tomas] } })
|
|
await page.goto('/app/face')
|
|
await expect(page.getByRole('heading', { name: 'Faces', level: 1 })).toBeVisible()
|
|
await page.locator('#face-probe-image-file').setInputFiles(photoFile())
|
|
await page.getByTestId('identify-go').click()
|
|
const verdict = page.getByTestId('verdict')
|
|
await expect(verdict).toContainText('Probably Anna Kowalski')
|
|
await expect(verdict).toContainText('faceprint')
|
|
await expect(verdict).toContainText('under 0.35')
|
|
expect(calls.faceIdentify[0]).toMatchObject({ model: 'insightface-buffalo-l', threshold: 0.35 })
|
|
expect(calls.faceIdentify[0].img).toMatch(/^data:image\//)
|
|
await expect(page.getByTestId('known-row-f-anna')).toContainText('best match')
|
|
})
|
|
|
|
test('no face found: says what to try', async ({ page }) => {
|
|
await mockIdentity(page, { storedFaces: faces, faceIdentify: { status: 400, body: { error: { message: 'no face detected in the image' } } } })
|
|
await page.goto('/app/face')
|
|
await page.locator('#face-probe-image-file').setInputFiles(photoFile())
|
|
await page.getByTestId('identify-go').click()
|
|
await expect(page.getByTestId('identity-error')).toContainText('No face found in this photo')
|
|
await expect(page.getByTestId('identity-error')).toContainText('front-facing')
|
|
})
|
|
|
|
test('same person: sends both photos, optional anti-spoofing, draws the faces it found', async ({ page }) => {
|
|
const calls = await mockIdentity(page, { storedFaces: faces })
|
|
await page.goto('/app/face')
|
|
await page.getByRole('tab', { name: 'Same person?' }).click()
|
|
await page.locator('#face-a-image-file').setInputFiles(photoFile('a.png'))
|
|
await page.locator('#face-b-image-file').setInputFiles(photoFile('b.png'))
|
|
await page.getByText('Check for photos of photos').click()
|
|
await page.getByTestId('compare-go').click()
|
|
await expect(page.getByTestId('verdict')).toContainText('Probably the same person')
|
|
expect(calls.faceVerify[0]).toMatchObject({ anti_spoofing: true })
|
|
expect(calls.faceVerify[0].img1).toMatch(/^data:image\//)
|
|
await expect(page.locator('.biometrics-bbox')).toHaveCount(2)
|
|
await expect(page.getByTestId('cutoff-value')).toHaveText('0.35')
|
|
})
|
|
|
|
test('enrol a person: a photo copy is opt-in', async ({ page }) => {
|
|
const calls = await mockIdentity(page, { storedFaces: [] })
|
|
await page.goto('/app/face')
|
|
await expect(page.getByTestId('known-empty')).toContainText('Nobody enrolled in this browser yet')
|
|
await page.getByTestId('enrol-open').click()
|
|
const sheet = page.getByTestId('enrol-sheet')
|
|
await page.locator('#face-enrol-image-file').setInputFiles(photoFile())
|
|
await page.locator('#face-enrol-name').fill('Anna Kowalski')
|
|
await sheet.getByText('This person agreed').click()
|
|
await sheet.getByRole('button', { name: /^Enrol/ }).click()
|
|
await expect(sheet).toHaveCount(0)
|
|
expect(calls.faceRegister[0]).toMatchObject({ model: 'insightface-buffalo-l', name: 'Anna Kowalski' })
|
|
const stored = await page.evaluate((k) => JSON.parse(localStorage.getItem(k)), FACE_KEY)
|
|
expect(stored[0]).not.toHaveProperty('thumbnail')
|
|
await expect(page.getByTestId('known-list')).toContainText('Anna Kowalski')
|
|
})
|
|
|
|
test('delete uses the face forget call after the window', async ({ page }) => {
|
|
await page.clock.install()
|
|
const calls = await mockIdentity(page, { storedFaces: faces })
|
|
await page.goto('/app/face')
|
|
await page.getByRole('button', { name: 'Remove Anna Kowalski' }).click()
|
|
await page.clock.runFor(9000)
|
|
expect(calls.forget).toHaveLength(0)
|
|
await page.clock.runFor(2000)
|
|
await expect.poll(() => calls.forget.length).toBe(1)
|
|
expect(calls.forget[0]).toMatchObject({ face: true, id: 'f-anna' })
|
|
})
|
|
|
|
test('disabled: no face model', async ({ page }) => {
|
|
await mockIdentity(page, { faceModels: [] })
|
|
await page.goto('/app/face')
|
|
await expect(page.getByTestId('model-needed')).toContainText('Faces need a face model')
|
|
})
|
|
|
|
test('more tools stay reachable: detect and the raw faceprint', async ({ page }) => {
|
|
await mockIdentity(page, { storedFaces: faces })
|
|
await page.goto('/app/face')
|
|
await page.getByText('More tools').click()
|
|
await expect(page.getByRole('tab', { name: /Detect and analyze/ })).toBeVisible()
|
|
await expect(page.getByRole('tab', { name: 'Embedding' })).toBeVisible()
|
|
})
|
|
})
|