From 42412e8eeb10bbaa87de4313999caa3a179a99c9 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Tue, 1 Sep 2026 09:09:01 -0500 Subject: [PATCH] fix(e2e): restore the frontend build by typing the responses findOrFail searches (#254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main did not build, so every Portainer deploy failed with `npm run build` exit code 2. My regression from #241. findOrFail(items: T[], predicate: (item: T) => boolean) infers T from both parameters. The call sites annotate the predicate as documentation, and the arrays come from .json(), which is any and offers no competing candidate — so T became the one-field shape written in the lambda and every caller failed on the field it actually wanted. Array.prototype.find has no such problem, which is why the code this replaced type-checked. The four responses are now typed at their call sites, so T is inferred from real data and the predicates need no annotation. findOrFail additionally takes NoInfer on its predicate, so a stray annotation can never drive the element type again. The specs are better typed than before this change: `.json()` was plain any, and the annotations only ever documented a shape nothing enforced. I did not catch this because I verified with a bare `npx tsc --noEmit`, and tsconfig.json is `"include": ["src"]` — it structurally cannot see tests/. The specs are checked by the second command in `npm run build`, which is the step Docker runs and the step that failed. Verified this time with `npm run build` itself, plus lint, the unit suite, and two consecutive full e2e passes. Closes #254 Co-Authored-By: Claude Opus 5 --- .../tests/e2e/admin-disable-customer.spec.ts | 12 ++++++---- .../tests/e2e/admin-inline-category.spec.ts | 6 +++-- frontend/tests/e2e/admin-taxonomy.spec.ts | 6 +++-- frontend/tests/e2e/email-templates.spec.ts | 24 ++++++++++++------- frontend/tests/e2e/favorites-filter.spec.ts | 4 ++-- frontend/tests/e2e/support/findOrFail.ts | 9 ++++++- 6 files changed, 42 insertions(+), 19 deletions(-) diff --git a/frontend/tests/e2e/admin-disable-customer.spec.ts b/frontend/tests/e2e/admin-disable-customer.spec.ts index 937c47e..f7f93cb 100644 --- a/frontend/tests/e2e/admin-disable-customer.spec.ts +++ b/frontend/tests/e2e/admin-disable-customer.spec.ts @@ -33,10 +33,12 @@ test.describe('Disabling a customer account', () => { await accountModal.open(); await expect(accountModal.emailText(customer.email)).toBeVisible(); - const customers = await (await request.get('/api/admin/customers')).json(); + const customers: { id: number; email: string }[] = await ( + await request.get('/api/admin/customers') + ).json(); const id = findOrFail( customers, - (c: { email: string }) => c.email === customer.email, + (c) => c.email === customer.email, `the customer ${customer.email}` ).id; await request.post(`/api/admin/customers/${id}/disable`); @@ -56,10 +58,12 @@ test.describe('Disabling a customer account', () => { authModal, header }) => { - const customers = await (await request.get('/api/admin/customers')).json(); + const customers: { id: number; email: string }[] = await ( + await request.get('/api/admin/customers') + ).json(); const id = findOrFail( customers, - (c: { email: string }) => c.email === customer.email, + (c) => c.email === customer.email, `the customer ${customer.email}` ).id; await request.post(`/api/admin/customers/${id}/disable`); diff --git a/frontend/tests/e2e/admin-inline-category.spec.ts b/frontend/tests/e2e/admin-inline-category.spec.ts index 05997b4..e4a782b 100644 --- a/frontend/tests/e2e/admin-inline-category.spec.ts +++ b/frontend/tests/e2e/admin-inline-category.spec.ts @@ -31,12 +31,14 @@ test.describe('Inline category creation from the item form', () => { await adminInventory.confirmButton.click(); await expect(page.getByText('Item added')).toBeVisible(); - const items = await (await page.request.get('/api/admin/items')).json(); + const items: { name: string; category_name: string | null }[] = await ( + await page.request.get('/api/admin/items') + ).json(); // findOrFail already fails with the row count when the item is missing, // which the toBeTruthy assertion this replaces could not report. const saved = findOrFail( items, - (item: { name: string }) => item.name === itemName, + (item) => item.name === itemName, `the item named ${itemName}` ); expect(saved.category_name).toBe(categoryName); diff --git a/frontend/tests/e2e/admin-taxonomy.spec.ts b/frontend/tests/e2e/admin-taxonomy.spec.ts index 6896b67..bd6ace9 100644 --- a/frontend/tests/e2e/admin-taxonomy.spec.ts +++ b/frontend/tests/e2e/admin-taxonomy.spec.ts @@ -40,10 +40,12 @@ test.describe('Admin taxonomy', () => { // The table paginates and the shared database holds many tags, so the new // row is confirmed through the API rather than hunted for across pages. - const tags = await (await page.request.get('/api/admin/tags')).json(); + const tags: { name: string; color: string | null }[] = await ( + await page.request.get('/api/admin/tags') + ).json(); const created = findOrFail( tags, - (tag: { name: string }) => tag.name === `vintage-${RUN}`, + (tag) => tag.name === `vintage-${RUN}`, `the tag vintage-${RUN}` ); expect(created.color).toBeTruthy(); diff --git a/frontend/tests/e2e/email-templates.spec.ts b/frontend/tests/e2e/email-templates.spec.ts index 416d872..48fac51 100644 --- a/frontend/tests/e2e/email-templates.spec.ts +++ b/frontend/tests/e2e/email-templates.spec.ts @@ -50,10 +50,12 @@ test.describe('Editing the customer emails', () => { await expect(page.getByText('Password reset saved')).toBeVisible(); // Persisted, not merely accepted by the form. - const stored = await (await page.request.get('/api/admin/email-templates')).json(); + const stored: { key: string; subject: string | null; body: string | null }[] = await ( + await page.request.get('/api/admin/email-templates') + ).json(); const reset = findOrFail( stored, - (t: { key: string }) => t.key === 'passwordReset', + (t) => t.key === 'passwordReset', 'the passwordReset template' ); expect(reset.subject).toBe(subject); @@ -72,10 +74,12 @@ test.describe('Editing the customer emails', () => { await expect(page.getByText('the body must keep {{resetUrl}}')).toBeVisible(); // And nothing was stored. - const stored = await (await page.request.get('/api/admin/email-templates')).json(); + const stored: { key: string; subject: string | null; body: string | null }[] = await ( + await page.request.get('/api/admin/email-templates') + ).json(); const reset = findOrFail( stored, - (t: { key: string }) => t.key === 'passwordReset', + (t) => t.key === 'passwordReset', 'the passwordReset template' ); expect(reset.body).toBeNull(); @@ -95,10 +99,12 @@ test.describe('Editing the customer emails', () => { await expect(page.getByText('Password reset restored to the default')).toBeVisible(); - const stored = await (await page.request.get('/api/admin/email-templates')).json(); + const stored: { key: string; subject: string | null; body: string | null }[] = await ( + await page.request.get('/api/admin/email-templates') + ).json(); const reset = findOrFail( stored, - (t: { key: string }) => t.key === 'passwordReset', + (t) => t.key === 'passwordReset', 'the passwordReset template' ); expect(reset.subject).toBeNull(); @@ -124,10 +130,12 @@ test.describe('Previewing the customer emails', () => { // point: an admin sees the effect before committing to it. await expect(adminEmails.preview('Password reset').getByText(wording)).toBeVisible(); - const stored = await (await page.request.get('/api/admin/email-templates')).json(); + const stored: { key: string; subject: string | null; body: string | null }[] = await ( + await page.request.get('/api/admin/email-templates') + ).json(); expect(findOrFail( stored, - (t: { key: string }) => t.key === 'passwordReset', + (t) => t.key === 'passwordReset', 'the passwordReset template' ).body).toBeNull(); }); diff --git a/frontend/tests/e2e/favorites-filter.spec.ts b/frontend/tests/e2e/favorites-filter.spec.ts index ed0c700..45a6040 100644 --- a/frontend/tests/e2e/favorites-filter.spec.ts +++ b/frontend/tests/e2e/favorites-filter.spec.ts @@ -178,10 +178,10 @@ test.describe('Filtering the storefront by favorites', () => { await favorite(storefront, favoritePrompt, SELLS); const api = await createAdminContext(playwright); - const items = await (await api.get('/api/items')).json(); + const items: { id: number; name: string }[] = await (await api.get('/api/items')).json(); const sells = findOrFail( items, - (item: { name: string }) => item.name === SELLS, + (item) => item.name === SELLS, `the item named ${SELLS}` ); await sellItem(api, sells.id); diff --git a/frontend/tests/e2e/support/findOrFail.ts b/frontend/tests/e2e/support/findOrFail.ts index d971086..a21de5c 100644 --- a/frontend/tests/e2e/support/findOrFail.ts +++ b/frontend/tests/e2e/support/findOrFail.ts @@ -19,7 +19,14 @@ */ export function findOrFail( items: T[], - predicate: (item: T) => boolean, + // NoInfer so the element type comes from `items` alone. Callers annotate the + // predicate parameter as documentation — `(c: { email: string }) => ...` — and + // without this the compiler would infer T from that annotation instead, so + // findOrFail would return the one-field shape written in the lambda rather + // than the row. Every caller then failed on the field it actually wanted: + // `.id does not exist on type { email: string }`. Array.prototype.find has no + // such problem, which is why the code this replaced type-checked. + predicate: (item: NoInfer) => boolean, description: string ): T { const found = items.find(predicate); -- 2.54.0