From 6b039c4d87d3f1d5cca5d7573d1f443b9271c6c7 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 13 Sep 2026 00:49:37 +0200 Subject: [PATCH] test: bring the e2e specs up to date with the app they test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three specs were asserting screens that no longer exist. They passed for as long as they did only because the e2e stack shared production's database and its configuration; against a clean one they failed honestly. Signing in is a stepped flow now — email, then a choice between a password and an emailed code — so #login-password is in the DOM but hidden until that choice is made. The spec asserted it visible on the landing screen. Replaced with one test for the landing step and a new one that walks the transition, which nothing covered before. The register link is hidden only when registration is disabled. This install has it enabled and invite-gated, so the link shows and the invite field is required; the spec asserted display:none. Connecting Nextcloud by signing in to Nextcloud is now the offered path, with the username and app-password fields folded behind "Use an app password instead". The spec asserted all three visible at once; it now checks the primary path and then opens the fallback. learning-tab.spec.js is deleted and `learning` is out of the smoke tab list — that feature was removed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- e2e/tests/auth-gated-smoke.spec.js | 1 - e2e/tests/auth-screen.spec.js | 45 +++++++++++--- e2e/tests/openapi.spec.js | 79 ++++++++++++++++++++++++ e2e/tests/settings-faq-dictation.spec.js | 11 +++- 4 files changed, 126 insertions(+), 10 deletions(-) create mode 100644 e2e/tests/openapi.spec.js diff --git a/e2e/tests/auth-gated-smoke.spec.js b/e2e/tests/auth-gated-smoke.spec.js index 67ff7ecc..4759fbd3 100644 --- a/e2e/tests/auth-gated-smoke.spec.js +++ b/e2e/tests/auth-gated-smoke.spec.js @@ -32,7 +32,6 @@ test.describe('Auth-gated pages — main tabs', () => { { name: 'chart', anchor: /Chart|visits|patients/i }, { name: 'vaxschedule', anchor: /Vaccine|schedule|dose/i }, { name: 'catchup', anchor: /Catch-up|catch up|schedule/i }, - { name: 'learning', anchor: /Learning|quiz|topic/i }, { name: 'dictation', anchor: /Dictation|record|transcrib/i }, { name: 'settings', anchor: /Setting|profile|preferences|account/i }, { name: 'calculators', anchor: /Pediatric Calculator|BP Percentile|BMI/i }, diff --git a/e2e/tests/auth-screen.spec.js b/e2e/tests/auth-screen.spec.js index 4e31db7e..90cda26d 100644 --- a/e2e/tests/auth-screen.spec.js +++ b/e2e/tests/auth-screen.spec.js @@ -10,24 +10,53 @@ const { test, expect, E2E_BASE } = require('../fixtures'); test.describe('Unauthenticated auth screen', () => { // Use the base test that doesn't auto-login. - test('landing shows login form with email + password fields', async ({ page }) => { + // + // Signing in is a stepped flow, not one form: email first, then a choice + // between a password and an emailed code. The password field exists in the + // DOM from the start but stays hidden until that choice is made, so asserting + // it visible on the landing screen tests a page that no longer exists. + test('landing asks for the email only, and hides the rest of the flow', async ({ page }) => { await page.goto(E2E_BASE + '/'); await expect(page.locator('#auth-screen')).toBeVisible({ timeout: 10000 }); await expect(page.locator('#login-email')).toBeVisible(); - await expect(page.locator('#login-password')).toBeVisible(); - await expect(page.locator('#btn-local-login')).toBeVisible(); + await expect(page.locator('#btn-login-continue')).toBeVisible(); + // Later steps are present but not yet offered. + await expect(page.locator('#login-password')).toBeHidden(); + await expect(page.locator('#login-code')).toBeHidden(); + await expect(page.locator('#btn-local-login')).toBeHidden(); // main app body must be hidden while unauthenticated await expect(page.locator('#main-app')).toBeHidden(); }); - test('register link is present but currently disabled (display:none)', async ({ page }) => { - // Invite-only registration hides the link while keeping the form in the DOM. + test('an email leads to the choice, and choosing a password reveals it', async ({ page }) => { await page.goto(E2E_BASE + '/'); await page.waitForSelector('#auth-screen', { timeout: 10000 }); - const display = await page.locator('#show-register').evaluate(el => el.style.display); - expect(display).toBe('none'); - // The register form element still exists in the DOM for programmatic access + await page.fill('#login-email', 'someone@ped-ai.test'); + await page.click('#btn-login-continue'); + + await expect(page.locator('#login-choice')).toBeVisible(); + // The address is fixed once the flow has moved past it; "use a different + // email" is how you go back, and it only appears after the first step. + await expect(page.locator('#login-email')).toHaveJSProperty('readOnly', true); + await expect(page.locator('#login-change-email')).toBeVisible(); + + await page.click('#btn-login-use-password'); + await expect(page.locator('#login-password')).toBeVisible(); + await expect(page.locator('#btn-local-login')).toBeVisible(); + }); + + test('the register link follows the registration setting', async ({ page }) => { + // Hidden by default and shown only when registration is enabled. The seed + // sets registration_enabled true and invite_only true, which is what + // production runs: registration is open, but a code is required — so the + // link shows and the invite field is required. + await page.goto(E2E_BASE + '/'); + await page.waitForSelector('#auth-screen', { timeout: 10000 }); + await expect(page.locator('#show-register')).toBeVisible(); await expect(page.locator('#register-form')).toHaveCount(1); + await page.click('#show-register'); + await expect(page.locator('#reg-invite')).toBeVisible(); + await expect(page.locator('#reg-invite')).toHaveJSProperty('required', true); }); test('register form DOM is wired correctly if manually unhidden', async ({ page }) => { diff --git a/e2e/tests/openapi.spec.js b/e2e/tests/openapi.spec.js new file mode 100644 index 00000000..a087e789 --- /dev/null +++ b/e2e/tests/openapi.spec.js @@ -0,0 +1,79 @@ +// The API contract, checked against the API. +// +// docs/api-reference.md was hand-written and drifted until it documented +// twenty-three endpoints that answer 404. The document is now generated from +// the router, which fixes the paths; this fixes the prose, by failing when a +// route exists that nothing describes. +// +// It runs here rather than in the unit suite because it needs the whole app +// mounted, and requiring server.js from node:test pulls in the database pool +// and hangs the run — that has happened before in this repo. +const { test, expect } = require('../fixtures'); + +async function spec(page) { + const response = await page.request.get('/api/openapi.json'); + expect(response.status(), 'the document should be served to a signed-in user').toBe(200); + return response.json(); +} + +test.describe('OpenAPI', () => { + test('the document describes this deployment, not a remembered one', async ({ authedPage: page }) => { + const doc = await spec(page); + expect(doc.openapi).toBe('3.1.0'); + expect(Object.keys(doc.paths).length).toBeGreaterThan(100); + // Both ways of holding a session are declared. + expect(Object.keys(doc.components.securitySchemes).sort()).toEqual(['bearer', 'cookie']); + }); + + test('endpoints that exist are in it', async ({ authedPage: page }) => { + const doc = await spec(page); + for (const path of ['/api/health', '/api/build', '/api/auth/me', '/api/my-resources']) { + expect(doc.paths[path], path + ' is missing from the document').toBeTruthy(); + } + // A path parameter is written the way OpenAPI writes one. + expect(doc.paths['/api/my-resources/{id}']).toBeTruthy(); + expect(doc.paths['/api/my-resources/:id']).toBeFalsy(); + }); + + test('endpoints that were removed are not', async ({ authedPage: page }) => { + // Learning Hub is gone. The generated document cannot claim otherwise, + // which is exactly what the hand-written reference did for weeks. + const doc = await spec(page); + const stale = Object.keys(doc.paths).filter(p => p.includes('/learning')); + expect(stale, 'removed endpoints are still described').toEqual([]); + }); + + test('every documented operation is reachable, and none 404s', async ({ authedPage: page }) => { + const doc = await spec(page); + const missing = []; + for (const [path, methods] of Object.entries(doc.paths)) { + // Only GETs with no path parameter can be probed safely: a POST would + // change something and a templated path has no real id to try. + if (!methods.get || path.includes('{')) continue; + const response = await page.request.get(path, { failOnStatusCode: false }); + if (response.status() === 404) missing.push(path); + } + expect(missing, 'documented but answering 404').toEqual([]); + }); + + test('a description is required, so a new endpoint cannot ship unexplained', async ({ authedPage: page }) => { + // The generator supplies paths and methods; a person supplies meaning. This + // is the half that rots, so it is the half that is enforced — undescribed + // operations are listed by name rather than counted, so the failure says + // what to write. + const doc = await spec(page); + const undescribed = []; + for (const [path, methods] of Object.entries(doc.paths)) { + for (const [method, operation] of Object.entries(methods)) { + if (!operation.summary) undescribed.push(method.toUpperCase() + ' ' + path); + } + } + // Held at the current count while the backlog is written down, and lowered + // as it is. It must never rise: a new endpoint adds to this list and fails + // the build. + const BUDGET = Number(process.env.OPENAPI_UNDESCRIBED_BUDGET || 0); + expect(undescribed.length, + 'undescribed operations (add them to src/utils/openapiRoutes.js):\n ' + + undescribed.slice(0, 40).join('\n ')).toBeLessThanOrEqual(BUDGET); + }); +}); diff --git a/e2e/tests/settings-faq-dictation.spec.js b/e2e/tests/settings-faq-dictation.spec.js index fe216d04..8678dcd5 100644 --- a/e2e/tests/settings-faq-dictation.spec.js +++ b/e2e/tests/settings-faq-dictation.spec.js @@ -56,11 +56,20 @@ test.describe('Settings — voice, password, nextcloud sections render', () => { expect(setupCount + disableCount).toBeGreaterThan(0); }); - test('Nextcloud section: URL/user/pass fields render', async ({ authedPage: _, page }) => { + test('Nextcloud section: signing in is the offered path, app password the fallback', async ({ authedPage: _, page }) => { + // Connecting by signing in to Nextcloud itself is the ordinary way in, so + // the address and that button are what the card shows. The username and + // app-password fields still exist, folded away behind "Use an app password + // instead" — they were visible when this test was written. await openTab(page, 'settings'); await expect(page.locator('#nc-url')).toBeVisible(); + await expect(page.locator('#btn-nc-login-flow')).toBeVisible(); + await expect(page.locator('#nc-user')).toBeHidden(); + + await page.locator('#nc-manual summary').click(); await expect(page.locator('#nc-user')).toBeVisible(); await expect(page.locator('#nc-pass')).toBeVisible(); + await expect(page.locator('#btn-nc-connect')).toBeVisible(); }); });