feat(i18n): add internationalization support with English and Spanish translations
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s

feat(kiosk): implement kiosk report submission functionality with offline support

test(outbox): add tests for outbox functionality including report submission and error handling

feat(outbox): create outbox management for queued reports with IndexedDB persistence

feat(uuid): add UUID generation utility for client IDs

feat(qr): implement QR code generation for reporting links by site and area

feat(kiosk): create Kiosk page for reporting incidents without an account
This commit is contained in:
jayjennings committed 2026-10-06 11:38:40 -07:00
1 parent 3f6dff5f24
commit 3e964d1176
64 files changed
+10056 -267

No files matched your search

+6 -1
View File
@@ -12,7 +12,12 @@ export default async function globalSetup() {
}
await ok(await api.post('/api/setup', { data: { organizationName: 'E2E Plant', ...users.admin, password: PASSWORD } }))
const { site } = await ok(await api.post('/api/sites', { data: { name: 'Main Plant' } }))
await ok(await api.post(`/api/sites/${site.id}/areas`, { data: { name: 'Press Shop', kind: 'building' } }))
const { area } = await ok(await api.post(`/api/sites/${site.id}/areas`, { data: { name: 'Press Shop', kind: 'building' } }))
const { kiosk } = await ok(await api.post('/api/kiosks', { data: { name: 'Canteen tablet' }, headers: { origin: baseURL } }))
// Shared with the test workers
process.env.E2E_KIOSK_TOKEN = kiosk.token
process.env.E2E_SITE_ID = site.id
process.env.E2E_AREA_ID = area.id
for (const role of ['supervisor', 'reporter'] as const) {
await ok(await api.post('/api/users', { data: { ...users[role], role, password: PASSWORD } }))
}
+92
View File
@@ -0,0 +1,92 @@
import { expect, test } from '@playwright/test'
import { signIn, unique, users } from './fixtures'
const token = () => process.env.E2E_KIOSK_TOKEN!
const site = () => process.env.E2E_SITE_ID!
const area = () => process.env.E2E_AREA_ID!
test('saves a report offline and sends it when the connection returns', async ({ page, context }) => {
const title = unique('Offline report: frayed sling')
await signIn(page, 'reporter')
await page.goto('/report')
await expect(page.getByLabel('Incident title')).toBeVisible()
await context.setOffline(true)
await page.getByLabel('Incident title').fill(title)
await page.getByRole('button', { name: 'Submit report' }).click()
await expect(page.getByRole('heading', { name: 'Saved on this device' })).toBeVisible()
await expect(page.getByText('1 report waiting to send')).toBeVisible()
await context.setOffline(false)
await expect(page.getByText('1 report waiting to send')).toBeHidden({ timeout: 15_000 })
await page.goto('/incidents')
await expect(page.getByRole('link', { name: title })).toBeVisible()
})
test('kiosk: reports without an account, in Spanish, from a QR code', async ({ browser, page }) => {
const title = unique('Cable suelto en el pasillo')
const kiosk = await (await browser.newContext({ locale: 'es-MX' })).newPage()
await kiosk.goto(`/k/${token()}?site=${site()}&area=${area()}`)
await expect(kiosk.getByRole('heading', { name: 'Reporte un problema de seguridad' })).toBeVisible()
await expect(kiosk.getByLabel('Edificio o área')).toHaveValue(area())
await kiosk.getByText('Peligro', { exact: true }).click()
await kiosk.getByText('Alta', { exact: true }).click()
await kiosk.getByLabel('En pocas palabras, ¿qué vio?').fill(title)
await kiosk.getByLabel('Su nombre').fill('Luis')
await kiosk.getByRole('button', { name: 'Enviar reporte' }).click()
await expect(kiosk.getByRole('heading', { name: 'Gracias' })).toBeVisible()
const reference = (await kiosk.getByText(/Su reporte INC-\d{4}/).textContent())!.match(/INC-\d{4}/)![0]
// Switching language works on the kiosk too
await kiosk.getByRole('button', { name: 'Reportar otra cosa' }).click()
await kiosk.getByRole('button', { name: 'English' }).click()
await expect(kiosk.getByRole('heading', { name: 'Report a safety concern' })).toBeVisible()
// The safety team sees who reported it and through which link
await signIn(page, 'admin')
await page.goto(`/incidents/${Number(reference.slice(4))}`)
await expect(page.getByRole('heading', { level: 1 })).toHaveText(title)
await expect(page.getByText('Reported by Luis via Canteen tablet')).toBeVisible()
await expect(page.getByText('Press Shop').first()).toBeVisible()
})
test('kiosk: a made-up or revoked link explains itself', async ({ page }) => {
await page.goto('/k/not-a-real-token')
await expect(page.getByText('This reporting link isn’t working')).toBeVisible()
})
test('QR codes open the report form with the location filled in', async ({ page, isMobile }) => {
test.skip(isMobile, 'Printing QR sheets is a desktop task')
await signIn(page, 'admin')
await page.goto(`/locations/qr?site=${site()}`)
await expect(page.getByRole('img', { name: /QR code for/ })).toHaveCount(2) // whole site + Press Shop
const url = await page.locator('main').getByText(/\/report\?site=/).nth(1).textContent()
expect(url).toContain(`area=${area()}`)
await page.goto(url!.trim())
await expect(page.getByText('Location filled in from the QR code.')).toBeVisible()
await expect(page.getByLabel('Building or area')).toHaveValue(area())
})
test('the signed-in app speaks Spanish too', async ({ page, isMobile }) => {
await signIn(page, 'reporter')
if (isMobile) await page.getByRole('button', { name: 'Open menu' }).click()
await page.getByRole('button', { name: 'Español' }).click()
if (isMobile) await page.getByRole('button', { name: 'Cerrar menú' }).click()
await page.goto('/report')
await expect(page.getByRole('heading', { name: 'Reportar un incidente' })).toBeVisible()
await expect(page.locator('html')).toHaveAttribute('lang', 'es')
await page.goto('/actions')
await expect(page.getByRole('heading', { name: 'Mis acciones' })).toBeVisible()
expect(users.reporter.email).toBeTruthy()
})
test('is installable: manifest and service worker', async ({ page }) => {
await page.goto('/login')
const manifest = await (await page.request.get('/manifest.webmanifest')).json()
expect(manifest).toMatchObject({ name: 'Open SafetyLog EHS', display: 'standalone' })
expect(manifest.icons.map((icon: { sizes: string }) => icon.sizes)).toEqual(expect.arrayContaining(['192x192', '512x512']))
const scope = await page.evaluate(async () => (await navigator.serviceWorker.ready).scope)
expect(scope).toMatch(/\/$/)
})