fix(e2e): restore the frontend build by typing the responses findOrFail searches (#254)
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:
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user