fix(e2e): restore the frontend build by typing the responses findOrFail searches (#254)
Linting / lint (pull_request) Successful in 2m10s
SonarQube Analysis / sonarqube (pull_request) Successful in 28m5s

main did not build, so every Portainer deploy failed with `npm run build` exit code 2. My regression from #241.

findOrFail<T>(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<T> 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 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 09:09:01 -05:00
co-authored by Claude Opus 5
parent 3ab265a653
commit 42412e8eeb
6 changed files with 42 additions and 19 deletions
@@ -33,10 +33,12 @@ test.describe('Disabling a customer account', () => {
await accountModal.open(); await accountModal.open();
await expect(accountModal.emailText(customer.email)).toBeVisible(); 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( const id = findOrFail(
customers, customers,
(c: { email: string }) => c.email === customer.email, (c) => c.email === customer.email,
`the customer ${customer.email}` `the customer ${customer.email}`
).id; ).id;
await request.post(`/api/admin/customers/${id}/disable`); await request.post(`/api/admin/customers/${id}/disable`);
@@ -56,10 +58,12 @@ test.describe('Disabling a customer account', () => {
authModal, authModal,
header 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( const id = findOrFail(
customers, customers,
(c: { email: string }) => c.email === customer.email, (c) => c.email === customer.email,
`the customer ${customer.email}` `the customer ${customer.email}`
).id; ).id;
await request.post(`/api/admin/customers/${id}/disable`); await request.post(`/api/admin/customers/${id}/disable`);
@@ -31,12 +31,14 @@ test.describe('Inline category creation from the item form', () => {
await adminInventory.confirmButton.click(); await adminInventory.confirmButton.click();
await expect(page.getByText('Item added')).toBeVisible(); 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, // findOrFail already fails with the row count when the item is missing,
// which the toBeTruthy assertion this replaces could not report. // which the toBeTruthy assertion this replaces could not report.
const saved = findOrFail( const saved = findOrFail(
items, items,
(item: { name: string }) => item.name === itemName, (item) => item.name === itemName,
`the item named ${itemName}` `the item named ${itemName}`
); );
expect(saved.category_name).toBe(categoryName); expect(saved.category_name).toBe(categoryName);
+4 -2
View File
@@ -40,10 +40,12 @@ test.describe('Admin taxonomy', () => {
// The table paginates and the shared database holds many tags, so the new // 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. // 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( const created = findOrFail(
tags, tags,
(tag: { name: string }) => tag.name === `vintage-${RUN}`, (tag) => tag.name === `vintage-${RUN}`,
`the tag vintage-${RUN}` `the tag vintage-${RUN}`
); );
expect(created.color).toBeTruthy(); expect(created.color).toBeTruthy();
+16 -8
View File
@@ -50,10 +50,12 @@ test.describe('Editing the customer emails', () => {
await expect(page.getByText('Password reset saved')).toBeVisible(); await expect(page.getByText('Password reset saved')).toBeVisible();
// Persisted, not merely accepted by the form. // 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( const reset = findOrFail(
stored, stored,
(t: { key: string }) => t.key === 'passwordReset', (t) => t.key === 'passwordReset',
'the passwordReset template' 'the passwordReset template'
); );
expect(reset.subject).toBe(subject); 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(); await expect(page.getByText('the body must keep {{resetUrl}}')).toBeVisible();
// And nothing was stored. // 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( const reset = findOrFail(
stored, stored,
(t: { key: string }) => t.key === 'passwordReset', (t) => t.key === 'passwordReset',
'the passwordReset template' 'the passwordReset template'
); );
expect(reset.body).toBeNull(); 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(); 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( const reset = findOrFail(
stored, stored,
(t: { key: string }) => t.key === 'passwordReset', (t) => t.key === 'passwordReset',
'the passwordReset template' 'the passwordReset template'
); );
expect(reset.subject).toBeNull(); expect(reset.subject).toBeNull();
@@ -124,10 +130,12 @@ test.describe('Previewing the customer emails', () => {
// point: an admin sees the effect before committing to it. // point: an admin sees the effect before committing to it.
await expect(adminEmails.preview('Password reset').getByText(wording)).toBeVisible(); 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( expect(findOrFail(
stored, stored,
(t: { key: string }) => t.key === 'passwordReset', (t) => t.key === 'passwordReset',
'the passwordReset template' 'the passwordReset template'
).body).toBeNull(); ).body).toBeNull();
}); });
+2 -2
View File
@@ -178,10 +178,10 @@ test.describe('Filtering the storefront by favorites', () => {
await favorite(storefront, favoritePrompt, SELLS); await favorite(storefront, favoritePrompt, SELLS);
const api = await createAdminContext(playwright); 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( const sells = findOrFail(
items, items,
(item: { name: string }) => item.name === SELLS, (item) => item.name === SELLS,
`the item named ${SELLS}` `the item named ${SELLS}`
); );
await sellItem(api, sells.id); await sellItem(api, sells.id);
+8 -1
View File
@@ -19,7 +19,14 @@
*/ */
export function findOrFail<T>( export function findOrFail<T>(
items: T[], 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<T>) => boolean,
description: string description: string
): T { ): T {
const found = items.find(predicate); const found = items.find(predicate);