531 lines
18 KiB
JavaScript
531 lines
18 KiB
JavaScript
import 'qrcode/qrcode-reader.js'
|
|
import { expect, test, vi } from 'vitest'
|
|
import ZXingBrowser from 'qrcode/zxing-browser.min.js'
|
|
import nacl from 'qrcode/nacl.min.js'
|
|
|
|
// private-key: 98f986e1afe2b546d264e45f00eb3f8d1b331bf3d2fa9e73bea3d8b2c9d90274
|
|
//
|
|
const okCodeData =
|
|
'3%73QJ0UK5G45S4XE0+GEUIKU662$D$K0RTM$4R7L7UX0V19FMFR5A++S6BNFR26 IJ7V15NE1NV2RN+T' +
|
|
'VH6%PHBDL*:01/69%EPEDUF7Y47EM6JA7MA79W5DOC$CC4S6G-CBW5C%6$Q6J*6JOCIPC4DC646FM6NE1' +
|
|
'AECPED-EDLFF QEGEC9VE634L%6 47%47C%6446D36K/EXVDAVCRWEV2C0/DUF7:967461R67:6OA7Y$5' +
|
|
'DE1HEC1WE..DF$DUF7/B8-ED:JCSTDR.C4LE1WE..DF$DUF771904E93DKOEXVDKPCF/DV CP9EIEC*ZC' +
|
|
'T34ZA8.Q6$Q6HB8A0'
|
|
|
|
const invalidCodeData = 'https://georges-abitbol.fr'
|
|
|
|
const invalidSignatureData =
|
|
'SVF.WB899RLB0%7NFKM.IWSBLTVZ65OQKD59REJ+ I/IDL960%HL%F 5EVVK397HQGIUK3OAPOP0RF/X' +
|
|
'L1FIKJG08J5 LE.G9%EPEDUF7M.C%47SW64DC4W5NF6$CC1S64VCBW53ECQZCGPCSG6C%6YZC:DC-96N' +
|
|
'E1AECPED-EDLFF QEGEC9VE634L%6 47%47C%6446D36K/EXVDAVCRWEV2C0/DUF7:967461R67:6OA7' +
|
|
'Y$5DE1HEC1WE..DF$DUF7/B8-ED:JCSTDR.C4LE1WE..DF$DUF771904E93DKOEXVDKPCF/DV CP9EIE' +
|
|
'C*ZCT34ZA8.Q6$Q6HB8A0'
|
|
|
|
const certificateWithoutValidity =
|
|
':U8$JSK1IVMJP$E06JCBCVQSIXFZ$HEDJ+S3:%1YN13BHJ$GFGLIN88AL5UNSFG8UG+YUV 2J6701809' +
|
|
'B3X3PP0TH9CN2R3IYEDB$DKWEOED0%EIEC ED3.DQ34%R83:6NW6XM8ME1.$E8UCE44QIC+96M.C9%6I' +
|
|
'M6E467W5LA76L6%47G%64W5ZJCYX6J*64EC6:6J$6'
|
|
|
|
const qrcodeReaderTest = test.extend({
|
|
mock: async ({ task }, use) => {
|
|
let resolveResult = undefined
|
|
let resultPromise = new Promise((resolve) => resolveResult = resolve)
|
|
let resolveResultHandled = undefined
|
|
const terminate = Symbol()
|
|
|
|
class MockBrowserQRCodeReader {
|
|
async decodeFromVideoDevice(device, element, callback) {
|
|
while(true) {
|
|
const result = await resultPromise
|
|
|
|
if(result === terminate) {
|
|
return
|
|
}
|
|
|
|
resultPromise = new Promise((resolve) => resolveResult = resolve)
|
|
|
|
callback({ text: result })
|
|
|
|
resolveResultHandled()
|
|
}
|
|
}
|
|
}
|
|
|
|
window.nacl = nacl
|
|
navigator.mediaDevices = true
|
|
const savedZXingBrowser = window.ZXingBrowser
|
|
window.ZXingBrowser = { BrowserQRCodeReader : MockBrowserQRCodeReader }
|
|
|
|
const reader = document.createElement('qrcode-reader')
|
|
|
|
// private-key: 98f986e1afe2b546d264e45f00eb3f8d1b331bf3d2fa9e73bea3d8b2c9d90274
|
|
reader.setAttribute('verify-key', '15dbdd38a2d8a2db1b4dd985da8da2b4e6b785b28db3fe0b34a10cfb3ba0aeb3')
|
|
document.append(reader)
|
|
|
|
const fetchBackup = global.fetch
|
|
global.fetch = vi.fn()
|
|
|
|
vi.useFakeTimers({toFake: ['Date']})
|
|
await use({
|
|
reader,
|
|
scan: async (text) => {
|
|
const resultHandled = new Promise((resolve) => resolveResultHandled = resolve)
|
|
resolveResult(text)
|
|
await resultHandled
|
|
}
|
|
})
|
|
vi.useRealTimers()
|
|
|
|
global.fetch = fetchBackup
|
|
|
|
reader.remove()
|
|
|
|
resolveResult(terminate)
|
|
window.ZXingBrowser = savedZXingBrowser
|
|
navigator.mediaDevices = undefined
|
|
window.nacl = undefined
|
|
}
|
|
})
|
|
|
|
test('qrcode reader shows a warning message if not supported on platform', async ({mock}) => {
|
|
const reader = document.createElement('qrcode-reader')
|
|
reader.setAttribute('verify-key', 'f81af42f9f9422d2393859d40994a42cdb2ef68507f056292ac96d1de1f1af83')
|
|
document.append(reader)
|
|
|
|
expect(reader.innerText).toBe('not_supported')
|
|
})
|
|
|
|
qrcodeReaderTest('qrcode reader shows valid qrcode informations', async ({mock}) => {
|
|
const { reader, scan } = mock
|
|
|
|
vi.setSystemTime(new Date(2023, 11, 1))
|
|
await scan(okCodeData)
|
|
|
|
const popup = reader.querySelector('.qrcode-reader--popup')
|
|
const title = popup.querySelector('.qrcode-reader--popup-title')
|
|
|
|
expect(popup.classList.contains('closed')).toBe(false)
|
|
expect(popup.classList.contains('error')).toBe(false)
|
|
expect(title.innerText).toBe('valid')
|
|
|
|
const validity = popup.querySelector('.qrcode-reader--validity')
|
|
|
|
expect(validity.innerText).toMatch(/from :\s*10\/31\/2023, 11:00:00 PM\s*to :\s*12\/1\/2023, 10:59:59 PM/)
|
|
|
|
const labels = popup.querySelectorAll('.qrcode-reader--data-item-label')
|
|
|
|
expect(labels.length).toBe(3)
|
|
expect(labels[0].innerText).toMatch('last_name')
|
|
expect(labels[1].innerText).toMatch('first_name')
|
|
expect(labels[2].innerText).toMatch('license_plate')
|
|
|
|
const values = popup.querySelectorAll('.qrcode-reader--data-item-value')
|
|
|
|
expect(values.length).toBe(3)
|
|
expect(values[0].innerText).toMatch('Abitbol')
|
|
expect(values[1].innerText).toMatch('Georges')
|
|
expect(values[2].innerText).toMatch('HA-424-AH')
|
|
|
|
const closeButton = reader.querySelector('.qrcode-reader--close-popup-button')
|
|
closeButton.dispatchEvent(new Event('click'))
|
|
|
|
expect(popup.classList.contains('closed')).toBe(true)
|
|
})
|
|
|
|
qrcodeReaderTest('qrcode reader shows error on not yet valid or expired qrcodes', async ({mock}) => {
|
|
const { reader, scan } = mock
|
|
vi.setSystemTime(new Date(2023, 9, 31)) // monthes start at 0 index, wtf javascript
|
|
|
|
await scan(okCodeData)
|
|
|
|
const popup = reader.querySelector('.qrcode-reader--popup')
|
|
const title = popup.querySelector('.qrcode-reader--popup-title')
|
|
const closeButton = reader.querySelector('.qrcode-reader--close-popup-button')
|
|
|
|
expect(popup.classList.contains('closed')).toBe(false)
|
|
expect(popup.classList.contains('error')).toBe(true)
|
|
expect(title.innerText).toBe('not_yet_valid')
|
|
|
|
closeButton.dispatchEvent(new Event('click'))
|
|
|
|
vi.setSystemTime(new Date(2023, 11, 2)) // monthes start at 0 index, wtf javascript
|
|
await scan(okCodeData)
|
|
|
|
expect(popup.classList.contains('closed')).toBe(false)
|
|
expect(popup.classList.contains('error')).toBe(true)
|
|
expect(title.innerText).toBe('expired')
|
|
})
|
|
|
|
qrcodeReaderTest('qrcode reader shows error on invalid qrcode', async ({mock}) => {
|
|
const { reader, scan } = mock
|
|
|
|
await scan(invalidCodeData)
|
|
|
|
const popup = reader.querySelector('.qrcode-reader--popup')
|
|
const title = reader.querySelector('.qrcode-reader--popup-title')
|
|
const content = reader.querySelector('.qrcode-reader--popup-content')
|
|
|
|
expect(popup.classList.contains('closed')).toBe(false)
|
|
expect(popup.classList.contains('error')).toBe(true)
|
|
expect(title.innerText).toBe('invalid_title')
|
|
expect(content.innerText).toBe('invalid_qrcode')
|
|
|
|
const closeButton = reader.querySelector('.qrcode-reader--close-popup-button')
|
|
closeButton.dispatchEvent(new Event('click'))
|
|
|
|
expect(popup.classList.contains('closed')).toBe(true)
|
|
})
|
|
|
|
qrcodeReaderTest('qrcode reader shows error on invalid signature', async ({mock}) => {
|
|
const { reader, scan } = mock
|
|
|
|
await scan(invalidSignatureData)
|
|
|
|
const popup = reader.querySelector('.qrcode-reader--popup')
|
|
const title = reader.querySelector('.qrcode-reader--popup-title')
|
|
const content = reader.querySelector('.qrcode-reader--popup-content')
|
|
|
|
expect(popup.classList.contains('closed')).toBe(false)
|
|
expect(popup.classList.contains('error')).toBe(true)
|
|
expect(title.innerText).toBe('invalid_title')
|
|
expect(content.innerText).toBe('invalid_signature')
|
|
|
|
const closeButton = reader.querySelector('.qrcode-reader--close-popup-button')
|
|
closeButton.dispatchEvent(new Event('click'))
|
|
|
|
expect(popup.classList.contains('closed')).toBe(true)
|
|
})
|
|
|
|
qrcodeReaderTest('qrcode reader can toggle fullscreen', async ({mock}) => {
|
|
const { reader } = mock
|
|
const fullscreenButton = reader.querySelector('.qrcode-reader--fullscreen-button')
|
|
|
|
reader.requestFullscreen = vi.fn()
|
|
document.exitFullscreen = vi.fn()
|
|
|
|
fullscreenButton.dispatchEvent(new Event('click'))
|
|
|
|
expect(reader.requestFullscreen).toHaveBeenCalled()
|
|
expect(document.exitFullscreen).not.toHaveBeenCalled()
|
|
|
|
vi.clearAllMocks()
|
|
document.fullscreenElement = reader
|
|
|
|
fullscreenButton.dispatchEvent(new Event('click'))
|
|
|
|
expect(reader.requestFullscreen).not.toHaveBeenCalled()
|
|
expect(document.exitFullscreen).toHaveBeenCalled()
|
|
|
|
expect(reader.classList.contains('fullscreen')).toBe(false)
|
|
|
|
document.fullscreenElement = reader
|
|
reader.dispatchEvent(new Event('fullscreenchange'))
|
|
expect(reader.classList.contains('fullscreen')).toBe(true)
|
|
|
|
document.fullscreenElement = undefined
|
|
reader.dispatchEvent(new Event('fullscreenchange'))
|
|
expect(reader.classList.contains('fullscreen')).toBe(false)
|
|
})
|
|
|
|
qrcodeReaderTest('qrcode reader accepts certificate without validity dates', async ({mock}) => {
|
|
const { reader, scan } = mock
|
|
|
|
await scan(certificateWithoutValidity)
|
|
|
|
const popup = reader.querySelector('.qrcode-reader--popup')
|
|
const title = popup.querySelector('.qrcode-reader--popup-title')
|
|
const validity = popup.querySelector('.qrcode-reader--validity')
|
|
|
|
expect(popup.classList.contains('closed')).toBe(false)
|
|
expect(popup.classList.contains('error')).toBe(false)
|
|
expect(title.innerText).toBe('valid')
|
|
expect(validity.innerText.trim().split(/\s+/)).toStrictEqual(["from", ":", "always", "to", ":", "never"])
|
|
})
|
|
|
|
const qrcodeReaderMetadataTest = qrcodeReaderTest.extend({
|
|
loadMetadata: async ({ task, mock }, use) => {
|
|
const loadMetadata = async (qrCodeData, mockFetch) => {
|
|
const { reader, scan } = mock
|
|
reader.setAttribute('metadata-url', 'https://orson-welles.io')
|
|
|
|
let resolveLoadingEnded = undefined
|
|
const loadingEnded = new Promise((resolve) => resolveLoadingEnded = resolve)
|
|
fetch.mockImplementationOnce(async (url) => {
|
|
return mockFetch(url)
|
|
})
|
|
|
|
await scan(qrCodeData)
|
|
|
|
// Wait for scan promises to finish
|
|
await new Promise((resolve) => setTimeout(resolve))
|
|
fetch.mockReset()
|
|
}
|
|
|
|
await use(loadMetadata)
|
|
}
|
|
})
|
|
|
|
qrcodeReaderMetadataTest('qrcode reader fetches metadata', async ({mock, loadMetadata}) => {
|
|
const {reader} = mock
|
|
await loadMetadata(okCodeData, async (url) => {
|
|
expect(url).toBe('https://orson-welles.io?certificate=853b9497-6c1a-4ff1-8604-6dc7cca9003c')
|
|
const spinner = reader.querySelector('.qrcode-reader--spinner')
|
|
expect(spinner).not.toBe(null)
|
|
return {
|
|
ok: true,
|
|
json: () => new Promise((resolve) => {
|
|
resolve({'err': 0, 'data': {'Classe': 'Oui', 'Ravioles': 'Non'}})
|
|
})
|
|
}
|
|
})
|
|
|
|
const spinner = reader.querySelector('.qrcode-reader--spinner')
|
|
expect(spinner).toBe(null)
|
|
|
|
const labels = reader.querySelectorAll('.qrcode-reader--metadata-items .qrcode-reader--data-item-label')
|
|
|
|
expect(labels.length).toBe(2)
|
|
expect(labels[0].innerText).toMatch('Classe')
|
|
expect(labels[1].innerText).toMatch('Ravioles')
|
|
|
|
const values = reader.querySelectorAll('.qrcode-reader--metadata-items .qrcode-reader--data-item-value')
|
|
|
|
expect(values.length).toBe(2)
|
|
expect(values[0].innerText).toMatch('Oui')
|
|
expect(values[1].innerText).toMatch('Non')
|
|
})
|
|
|
|
|
|
qrcodeReaderMetadataTest('qrcode reader show feedback on metadata network error', async ({mock, loadMetadata}) => {
|
|
const { reader, scan } = mock
|
|
await loadMetadata(okCodeData, async (url) => {
|
|
throw { name: 'NetworkError' }
|
|
})
|
|
|
|
const spinner = reader.querySelector('.qrcode-reader--spinner')
|
|
expect(spinner).toBe(null)
|
|
|
|
const items = reader.querySelector('.qrcode-reader--metadata-items')
|
|
expect(items.classList.contains('error')).toBe(true)
|
|
expect(items.innerText).toBe('metadata_network_error')
|
|
})
|
|
|
|
qrcodeReaderMetadataTest('qrcode reader show feedback on http error', async ({mock, loadMetadata}) => {
|
|
const { reader, scan } = mock
|
|
await loadMetadata(okCodeData, async (url) => {
|
|
return {
|
|
ok: false,
|
|
json: () => new Promise((resolve) => resolve({}))
|
|
}
|
|
})
|
|
|
|
const spinner = reader.querySelector('.qrcode-reader--spinner')
|
|
expect(spinner).toBe(null)
|
|
|
|
const items = reader.querySelector('.qrcode-reader--metadata-items')
|
|
expect(items.classList.contains('error')).toBe(true)
|
|
expect(items.innerText.trim()).toBe('metadata_api_error')
|
|
})
|
|
|
|
qrcodeReaderMetadataTest('qrcode reader show feedback on api error', async ({mock, loadMetadata}) => {
|
|
const { reader, scan } = mock
|
|
|
|
await loadMetadata(okCodeData, async (url) => {
|
|
return {
|
|
ok: true,
|
|
json: () => new Promise((resolve) => {
|
|
resolve({'err': 1})
|
|
})
|
|
}
|
|
})
|
|
|
|
const spinner = reader.querySelector('.qrcode-reader--spinner')
|
|
expect(spinner).toBe(null)
|
|
|
|
const items = reader.querySelector('.qrcode-reader--metadata-items')
|
|
expect(items.classList.contains('error')).toBe(true)
|
|
expect(items.innerText.trim()).toBe('metadata_api_error')
|
|
})
|
|
|
|
qrcodeReaderMetadataTest("qrcode reader doesn't refetch metadata for the same certificate if popup is opened", async ({mock, loadMetadata}) => {
|
|
const { reader, scan } = mock
|
|
|
|
await loadMetadata(okCodeData, async (url) => {
|
|
return {
|
|
ok: true,
|
|
json: async () => ({'err': 0, 'data': {'Classe': 'Oui', 'Ravioles': 'Non'}})
|
|
}
|
|
})
|
|
|
|
let items = reader.querySelector('.qrcode-reader--metadata-items')
|
|
|
|
// loading the same certificate doesn't refeches metadata
|
|
await loadMetadata(okCodeData, async (url) => {
|
|
expect.unreachable()
|
|
})
|
|
|
|
expect(reader.querySelector('.qrcode-reader--metadata-items')).toBe(items)
|
|
|
|
// loading another certificate refetches metadata
|
|
await loadMetadata(certificateWithoutValidity, async (url) => {
|
|
return {
|
|
ok: true,
|
|
json: async () => ({'err': 0, 'data': {'Classe': 'Oui', 'Ravioles': 'Non'}})
|
|
}
|
|
})
|
|
|
|
expect(reader.querySelector('.qrcode-reader--metadata-items')).not.toBe(items)
|
|
items = reader.querySelector('.qrcode-reader--metadata-items')
|
|
|
|
const closeButton = reader.querySelector('.qrcode-reader--close-popup-button')
|
|
closeButton.dispatchEvent(new Event('click'))
|
|
|
|
// metadata is refetched for the same certificate after closing the popup
|
|
await loadMetadata(certificateWithoutValidity, async (url) => {
|
|
return {
|
|
ok: true,
|
|
json: async () => ({'err': 0, 'data': {'Classe': 'Oui', 'Ravioles': 'Non'}})
|
|
}
|
|
})
|
|
|
|
expect(reader.querySelector('.qrcode-reader--metadata-items')).not.toBe(items)
|
|
})
|
|
|
|
const qrcodeReaderTallyTest = qrcodeReaderTest.extend({
|
|
loadTally: async ({ task, mock }, use) => {
|
|
const loadTally = async (qrCodeData, mockFetch) => {
|
|
const { reader, scan } = mock
|
|
reader.setAttribute('tally-url', 'https://orson-welles.io')
|
|
|
|
let resolveLoadingEnded = undefined
|
|
fetch.mockImplementationOnce(async (url, {body}) => {
|
|
return mockFetch(url, JSON.parse(body))
|
|
})
|
|
|
|
await scan(qrCodeData)
|
|
await new Promise((resolve) => setTimeout(resolve))
|
|
fetch.mockReset()
|
|
}
|
|
|
|
await use(loadTally)
|
|
}
|
|
})
|
|
|
|
qrcodeReaderTallyTest('qrcode reader show tally status for unstamped certificates', async ({mock, loadTally}) => {
|
|
const { reader, scan } = mock
|
|
|
|
const nowBackup = Date.now
|
|
Date.now = () => 25000
|
|
await loadTally(okCodeData, async (url, body) => {
|
|
expect(body).toStrictEqual({
|
|
events: [{
|
|
certificate: '853b9497-6c1a-4ff1-8604-6dc7cca9003c',
|
|
timestamp: 25
|
|
}]
|
|
})
|
|
return {
|
|
ok: true,
|
|
json: async () => ({'err': 0, 'data': {'stamps': {[body.events[0].certificate]: 'ok'}}})
|
|
}
|
|
})
|
|
Date.now = nowBackup
|
|
|
|
const spinner = reader.querySelector('.qrcode-reader--spinner')
|
|
expect(spinner).toBe(null)
|
|
|
|
const items = reader.querySelector('.qrcode-reader--tally-status')
|
|
expect(items.classList.contains('error')).toBe(false)
|
|
expect(items.innerText.trim()).toBe('tally_status_ok')
|
|
})
|
|
|
|
qrcodeReaderTallyTest('qrcode reader show error for stamped certificates', async ({mock, loadTally}) => {
|
|
const { reader, scan } = mock
|
|
|
|
await loadTally(okCodeData, async (url, body) => {
|
|
return {
|
|
ok: true,
|
|
json: async () => ({'err': 0, 'data': {'stamps': {[body.events[0].certificate]: 'duplicate'}}})
|
|
}
|
|
})
|
|
|
|
const spinner = reader.querySelector('.qrcode-reader--spinner')
|
|
expect(spinner).toBe(null)
|
|
|
|
const items = reader.querySelector('.qrcode-reader--tally-status')
|
|
expect(items.classList.contains('error')).toBe(true)
|
|
expect(items.innerText.trim()).toBe('tally_status_already_seen')
|
|
})
|
|
|
|
qrcodeReaderTallyTest('qrcode reader show tally when request fails ', async ({mock, loadTally}) => {
|
|
const { reader, scan } = mock
|
|
|
|
await loadTally(okCodeData, async (url, body) => {
|
|
throw { name: 'NetworkError' }
|
|
})
|
|
|
|
const spinner = reader.querySelector('.qrcode-reader--spinner')
|
|
expect(spinner).toBe(null)
|
|
|
|
let items = reader.querySelector('.qrcode-reader--tally-status')
|
|
expect(items.classList.contains('error')).toBe(true)
|
|
expect(items.innerText.trim()).toBe('tally_network_error')
|
|
|
|
const closeButton = reader.querySelector('.qrcode-reader--close-popup-button')
|
|
closeButton.dispatchEvent(new Event('click'))
|
|
|
|
await loadTally(okCodeData, async (url, body) => {
|
|
return {
|
|
ok: false,
|
|
}
|
|
})
|
|
|
|
items = reader.querySelector('.qrcode-reader--tally-status')
|
|
expect(items.classList.contains('error')).toBe(true)
|
|
expect(items.innerText.trim()).toBe('tally_api_error')
|
|
})
|
|
|
|
qrcodeReaderTallyTest("qrcode reader doesn't refetch tally for the same certificate if popup is opened", async ({mock, loadTally}) => {
|
|
const { reader, scan } = mock
|
|
|
|
await loadTally(okCodeData, async (url, body) => {
|
|
return {
|
|
ok: true,
|
|
json: async () => ({'err': 0, 'data': {'stamps': {[body.events[0].certificate]: 'duplicate'}}})
|
|
}
|
|
})
|
|
|
|
let tallyStatus = reader.querySelector('.qrcode-reader--tally-status')
|
|
|
|
// loading the same certificate doesn't refeches metadata
|
|
await loadTally(okCodeData, async () => {
|
|
expect.unreachable()
|
|
})
|
|
|
|
expect(reader.querySelector('.qrcode-reader--tally-status')).toBe(tallyStatus)
|
|
|
|
// loading another certificate refetches tally status
|
|
await loadTally(certificateWithoutValidity, async (url, body) => {
|
|
return {
|
|
ok: true,
|
|
json: async () => ({'err': 0, 'data': {'stamps': {[body.events[0].certificate]: 'ok'}}})
|
|
}
|
|
})
|
|
|
|
expect(reader.querySelector('.qrcode-reader--tally-status')).not.toBe(tallyStatus)
|
|
tallyStatus = reader.querySelector('.qrcode-reader--tally-status')
|
|
|
|
const closeButton = reader.querySelector('.qrcode-reader--close-popup-button')
|
|
closeButton.dispatchEvent(new Event('click'))
|
|
|
|
// tally status is refetched for the same certificate after closing the popup
|
|
await loadTally(certificateWithoutValidity, async (url, body) => {
|
|
return {
|
|
ok: true,
|
|
json: async () => ({'err': 0, 'data': {'stamps': {[body.events[0].certificate]: 'ok'}}})
|
|
}
|
|
})
|
|
|
|
expect(reader.querySelector('.qrcode-reader--tally-status')).not.toBe(tallyStatus)
|
|
tallyStatus = reader.querySelector('.qrcode-reader--tally-status')
|
|
})
|