test: playkit v0.3.1 network monitors, authed filters, admin-frontend Vitest

Bump @levkin/playkit to v0.3.1 (network interception helpers) and land the
work it unblocked: startNetworkErrorMonitor on gallery/search/upload/manage-
users, Favorites + People filter UI e2e (storageState), and a fix for the
lingering `res.data: unknown` / Mailpit-vs-Mailtrap type errors in the mail
and catalog specs. Also adds Vitest coverage + a CI job for admin-frontend,
which had no test infra before this.
This commit is contained in:
2026-07-14 22:01:27 -04:00
parent 72e89aef9f
commit ccb961d87a
15 changed files with 814 additions and 37 deletions
+9 -4
View File
@@ -4,10 +4,15 @@ import { test, expect } from '../fixtures';
const PeopleList = z.object({
items: z.array(z.object({ id: z.number() }).passthrough()).min(1),
});
type PeopleList = z.infer<typeof PeopleList>;
const TagsList = z.object({
items: z.array(z.object({ id: z.number() }).passthrough()).min(1),
});
type TagsList = z.infer<typeof TagsList>;
const ItemsList = z.object({ items: z.array(z.unknown()) });
type ItemsList = z.infer<typeof ItemsList>;
/**
* Public / lightly-gated FastAPI surfaces — shape + status contracts via playkit ApiClient.
@@ -16,16 +21,16 @@ const TagsList = z.object({
test.describe('api catalog @smoke', () => {
test('GET /api/v1/people returns items list', async ({ api, timings }) => {
const res = await timings.measure('api_people', () =>
api.get('/api/v1/people', { expectedStatus: 200, schema: PeopleList }),
api.get<PeopleList>('/api/v1/people', { expectedStatus: 200, schema: PeopleList }),
);
expect(res.data.items[0]).toEqual(expect.objectContaining({ id: expect.any(Number) }));
});
test('GET /api/v1/people/with-faces returns items list', async ({ api, timings }) => {
const res = await timings.measure('api_people_faces', () =>
api.get('/api/v1/people/with-faces', {
api.get<ItemsList>('/api/v1/people/with-faces', {
expectedStatus: 200,
schema: z.object({ items: z.array(z.unknown()) }),
schema: ItemsList,
}),
);
expect(Array.isArray(res.data.items)).toBe(true);
@@ -33,7 +38,7 @@ test.describe('api catalog @smoke', () => {
test('GET /api/v1/tags returns items list', async ({ api, timings }) => {
const res = await timings.measure('api_tags', () =>
api.get('/api/v1/tags', { expectedStatus: 200, schema: TagsList }),
api.get<TagsList>('/api/v1/tags', { expectedStatus: 200, schema: TagsList }),
);
expect(res.data.items[0]).toEqual(expect.objectContaining({ id: expect.any(Number) }));
});
+3 -1
View File
@@ -3,6 +3,8 @@ import {
firstLinkMatching,
readMailHtml,
waitForUrlHost,
type MailpitMessage,
type MailtrapMessage,
} from '@levkin/playkit';
import { test, expect } from '../fixtures';
@@ -40,7 +42,7 @@ test.describe('mail @smoke', () => {
);
expect(res.ok()).toBeTruthy();
const msg = await timings.measure('mail_wait', () =>
const msg = await timings.measure<MailpitMessage | MailtrapMessage>('mail_wait', () =>
mail!.waitForEmail({
to,
subject: /reset|password/i,
+70
View File
@@ -0,0 +1,70 @@
import path from 'node:path';
import { test, expect } from '../fixtures';
/**
* Logged-in-only gallery filters: Favorites and People are hidden from the
* `FilterPanel` for anonymous visitors (see `FilterPanel.tsx` `isLoggedIn`
* gate), so these need the saved admin `storageState` from `auth.setup.ts`.
* Public tag/person query-param filtering is covered without login in
* `gallery.search-filters.spec.ts`.
*/
test.describe('gallery filters (authed) @smoke', () => {
test.use({ storageState: path.join(__dirname, '../.auth/admin.json') });
test('favorites filter checkbox is visible and toggles the URL param', async ({
page,
playkitConfig,
timings,
}) => {
await timings.measure('open_search', async () => {
await page.goto(`${playkitConfig.baseUrl}/search`);
});
const favoritesCheckbox = page.locator('#favorites-only');
await expect(favoritesCheckbox).toBeVisible({ timeout: 20_000 });
await expect(favoritesCheckbox).not.toBeChecked();
await page.getByText('Show favorites only').click();
await expect(favoritesCheckbox).toBeChecked();
await expect(page).toHaveURL(/favoritesOnly=true/);
await expect(
page.getByText(/Found \d+ photos?|No photos found matching your filters/i).first(),
).toBeVisible({ timeout: 20_000 });
// Toggling back off should drop the param again.
await page.getByText('Show favorites only').click();
await expect(favoritesCheckbox).not.toBeChecked();
await expect(page).not.toHaveURL(/favoritesOnly=true/);
});
test('people filter UI: selecting a person updates URL and result count', async ({
page,
playkitConfig,
api,
timings,
}) => {
const peopleRes = await api.get<{ items: Array<{ id: number; first_name: string; last_name: string }> }>(
'/api/v1/people',
{ expectedStatus: 200 },
);
const person = peopleRes.data.items[0];
expect(person).toBeTruthy();
await timings.measure('open_search', async () => {
await page.goto(`${playkitConfig.baseUrl}/search`);
});
await page.getByRole('button', { name: /select people/i }).click();
await page.getByPlaceholder('Search people...').fill(person.first_name);
await page
.getByText(`${person.first_name} ${person.last_name}`, { exact: true })
.click();
await page.keyboard.press('Escape');
await expect(page).toHaveURL(new RegExp(`people=${person.id}(&|$)`));
await expect(
page.getByText(/Found \d+ photos?|No photos found matching your filters/i).first(),
).toBeVisible({ timeout: 20_000 });
});
});
+84
View File
@@ -0,0 +1,84 @@
import path from 'node:path';
import { startNetworkErrorMonitor } from '@levkin/playkit';
import { test, expect } from '../fixtures';
/**
* Silent-failure guard: these pages can *look* fine in the UI while a
* background XHR/fetch quietly 4xx/5xxs (the exact class of bug
* `startNetworkErrorMonitor` exists to catch — see playkit `docs/NETWORK.md`).
* Requires playkit >= 0.3.1 (network helpers landed after the 0.3.0 tag).
*
* `/api/auth/session` 401s on the anonymous project by design (no cookie) —
* excluded rather than asserted on, since it's not the failure mode we're
* guarding against here.
*/
const excludeAnonSession = [/\/api\/auth\/session/];
test.describe('network errors (public pages) @smoke', () => {
test('gallery home stays clean', async ({ page, playkitConfig, timings }) => {
const net = startNetworkErrorMonitor(page, { excludePatterns: excludeAnonSession });
try {
await timings.measure('open_gallery', async () => {
await page.goto(`${playkitConfig.baseUrl}/`);
await expect(page.getByLabel('Account menu').or(page.getByRole('button', { name: /Sign in/i })).first()).toBeVisible({
timeout: 20_000,
});
});
} finally {
net.assertNoErrors();
}
});
test('search page with a tag filter stays clean', async ({ page, playkitConfig, api, timings }) => {
const tagsRes = await api.get<{ items: Array<{ id: number; tag_name: string }> }>(
'/api/v1/tags',
{ expectedStatus: 200 },
);
const tag = tagsRes.data.items[0];
expect(tag).toBeTruthy();
const net = startNetworkErrorMonitor(page, { excludePatterns: excludeAnonSession });
try {
await timings.measure('search_tag_filter', async () => {
await page.goto(`${playkitConfig.baseUrl}/search?tags=${tag.id}`);
await expect(
page.getByText(/Found \d+ photos?|No photos found matching your filters/i).first(),
).toBeVisible({ timeout: 20_000 });
});
} finally {
net.assertNoErrors();
}
});
});
test.describe('network errors (authed) @smoke', () => {
test.use({ storageState: path.join(__dirname, '../.auth/admin.json') });
test('upload page stays clean on load', async ({ page, playkitConfig, timings }) => {
const net = startNetworkErrorMonitor(page);
try {
await timings.measure('open_upload', async () => {
await page.goto(`${playkitConfig.baseUrl}/upload`);
await expect(page.getByLabel('Account menu')).toBeVisible({ timeout: 20_000 });
});
} finally {
net.assertNoErrors();
}
});
test('manage users overlay stays clean', async ({ page, playkitConfig, accountMenu, timings }) => {
await page.goto(`${playkitConfig.baseUrl}/`);
await expect(page.getByLabel('Account menu')).toBeVisible({ timeout: 20_000 });
const net = startNetworkErrorMonitor(page);
try {
await timings.measure('manage_users', async () => {
await accountMenu.openManageUsers();
await expect(page.getByRole('heading', { name: /Manage Users/i })).toBeVisible();
await accountMenu.closeManageUsers();
});
} finally {
net.assertNoErrors();
}
});
});
+3 -1
View File
@@ -3,6 +3,8 @@ import {
firstLinkMatching,
readMailHtml,
waitForUrlHost,
type MailpitMessage,
type MailtrapMessage,
} from '@levkin/playkit';
import { test, expect } from '../fixtures';
@@ -48,7 +50,7 @@ test.describe('auth register @mail', () => {
).toBeVisible({ timeout: 20_000 });
await waitForUrlHost(page, playkitConfig.expectedHost);
const msg = await timings.measure('mail_wait', () =>
const msg = await timings.measure<MailpitMessage | MailtrapMessage>('mail_wait', () =>
mail!.waitForEmail({
to: email,
subject: /confirm|verify|welcome|account/i,
+3 -1
View File
@@ -3,6 +3,8 @@ import {
firstLinkMatching,
readMailHtml,
waitForUrlHost,
type MailpitMessage,
type MailtrapMessage,
} from '@levkin/playkit';
import { test, expect } from '../fixtures';
@@ -36,7 +38,7 @@ test.describe('auth reset @mail', () => {
});
expect(res.ok()).toBeTruthy();
const msg = await timings.measure('mail_wait', () =>
const msg = await timings.measure<MailpitMessage | MailtrapMessage>('mail_wait', () =>
mail!.waitForEmail({ to, subject: /reset|password/i, after, timeoutMs: 90_000 }),
);
const html = await readMailHtml(mail!, msg as { ID?: string; id?: number; HTML?: string });