added e2e tests

This commit is contained in:
Dmitri 2026-07-07 08:17:54 +02:00
parent ec908c936d
commit 49917e6406
6 changed files with 234 additions and 29 deletions

View File

@ -42,19 +42,6 @@ TODO:
- Если нужен, оставить `localStorage`, но осознанно и с коротким TTL/refresh policy. - Если нужен, оставить `localStorage`, но осознанно и с коротким TTL/refresh policy.
- Заменить frontend `jsonwebtoken` decode на `jwt-decode` или `/auth/me`. - Заменить frontend `jsonwebtoken` decode на `jwt-decode` или `/auth/me`.
### Минимальные regression checks
Начать с smoke tests/checklist:
- login;
- constructor load/save;
- save to stage;
- publish;
- public production runtime;
- private production runtime grant;
- page navigation;
- rest.
## P1 - Build и зависимости ## P1 - Build и зависимости
### Package manager ### Package manager

View File

@ -34,8 +34,10 @@ The frontend uses two complementary test layers:
normalizers, and state-machine boundaries with Node's built-in test runner. normalizers, and state-machine boundaries with Node's built-in test runner.
- `npm run test:e2e` runs Playwright browser tests against the real Next.js app - `npm run test:e2e` runs Playwright browser tests against the real Next.js app
with controlled API fixtures. The E2E suite intentionally stays compact: with controlled API fixtures. The E2E suite intentionally stays compact:
auth redirects/login, authenticated shell routes, constructor canvas/toolbar auth redirects/login, authenticated shell routes, REST-backed list/filter/edit
responsiveness, and stage/production runtime smoke. flows, constructor load/save/save-to-stage, project publish, public and
private production runtime access, stage runtime, page navigation, and
constructor toolbar responsiveness.
Playwright fixtures live under `tests/e2e/` and mock backend endpoints at the Playwright fixtures live under `tests/e2e/` and mock backend endpoints at the
network boundary. This keeps tests deterministic while still exercising real network boundary. This keeps tests deterministic while still exercising real

View File

@ -1,5 +1,6 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { import {
TEST_PROJECT_ID,
authenticate, authenticate,
collectConsoleFailures, collectConsoleFailures,
mockFrontendApi, mockFrontendApi,
@ -38,3 +39,38 @@ test('protected project-dependent routes do not redirect when a project exists',
await expect(page).not.toHaveURL(/projects-new/); await expect(page).not.toHaveURL(/projects-new/);
await expect(page).not.toHaveURL(/login/); await expect(page).not.toHaveURL(/login/);
}); });
test('project detail publishes stage content to production', async ({
page,
}) => {
const consoleFailures = collectConsoleFailures(page);
await page.goto(`/projects/${TEST_PROJECT_ID}`);
await expect(
page.getByRole('heading', { name: testProject.name }),
).toBeVisible();
await page.getByRole('button', { name: 'Publish to Production' }).click();
await page
.getByPlaceholder('e.g. Release 1.0.3')
.fill('Playwright smoke publish');
await page
.getByPlaceholder('Describe what was published')
.fill('Minimal regression publish check');
const publishRequestPromise = page.waitForRequest((request) => {
const url = new URL(request.url());
return request.method() === 'POST' && url.pathname.endsWith('/publish');
});
await page.getByRole('button', { name: 'Confirm publish' }).click();
const publishRequest = await publishRequestPromise;
expect(publishRequest.postDataJSON()).toEqual({
projectId: TEST_PROJECT_ID,
title: 'Playwright smoke publish',
description: 'Minimal regression publish check',
});
await expect(page.getByText('Published: 2 pages')).toBeVisible();
consoleFailures.assertClean();
});

View File

@ -1,6 +1,7 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { import {
TEST_PROJECT_ID, TEST_PROJECT_ID,
TEST_PAGE_ONE_ID,
authenticate, authenticate,
collectConsoleFailures, collectConsoleFailures,
mockFrontendApi, mockFrontendApi,
@ -50,6 +51,55 @@ test('constructor loads project canvas, assets, and core controls', async ({
consoleFailures.assertClean(); consoleFailures.assertClean();
}); });
test('constructor saves dev page changes and promotes them to stage', async ({
page,
}) => {
const consoleFailures = collectConsoleFailures(page);
await page.goto(`/constructor?projectId=${TEST_PROJECT_ID}`);
await expect(page.getByRole('img', { name: 'Background' })).toBeVisible();
const saveRequestPromise = page.waitForRequest((request) => {
const url = new URL(request.url());
return (
request.method() === 'PUT' &&
url.pathname.endsWith(`/tour_pages/${TEST_PAGE_ONE_ID}`)
);
});
await page.getByRole('button', { name: /^Save/ }).click();
const saveRequest = await saveRequestPromise;
expect(saveRequest.postDataJSON()).toMatchObject({
id: TEST_PAGE_ONE_ID,
});
const stageSaveRequestPromise = page.waitForRequest((request) => {
const url = new URL(request.url());
return (
request.method() === 'PUT' &&
url.pathname.endsWith(`/tour_pages/${TEST_PAGE_ONE_ID}`)
);
});
const saveToStageRequestPromise = page.waitForRequest((request) => {
const url = new URL(request.url());
return (
request.method() === 'POST' &&
url.pathname.endsWith('/publish/save-to-stage')
);
});
await page.getByRole('button', { name: /^Stage/ }).click();
await stageSaveRequestPromise;
const saveToStageRequest = await saveToStageRequestPromise;
expect(saveToStageRequest.postDataJSON()).toEqual({
projectId: TEST_PROJECT_ID,
});
await expect(page.getByText('Saved to stage.')).toBeVisible();
consoleFailures.assertClean();
});
test('constructor toolbar remains reachable on narrow viewport', async ({ test('constructor toolbar remains reachable on narrow viewport', async ({
page, page,
}) => { }) => {

View File

@ -32,7 +32,26 @@ export const testPermissions = [
{ id: 'permission-update-permissions', name: 'UPDATE_PERMISSIONS' }, { id: 'permission-update-permissions', name: 'UPDATE_PERMISSIONS' },
]; ];
export const testUser = { type TestPermission = {
id: string;
name: string;
};
type TestUser = {
id: string;
email: string;
firstName: string;
lastName: string;
app_role: {
id: string;
name: string;
permissions: TestPermission[];
};
custom_permissions: TestPermission[];
allowedPrivateProductionSlugs: string[];
};
export const testUser: TestUser = {
id: 'user-playwright-admin', id: 'user-playwright-admin',
email: 'admin@example.test', email: 'admin@example.test',
firstName: 'Playwright', firstName: 'Playwright',
@ -46,6 +65,16 @@ export const testUser = {
allowedPrivateProductionSlugs: [], allowedPrivateProductionSlugs: [],
}; };
export const testPrivateRuntimeUser = {
...testUser,
app_role: {
id: 'role-public',
name: 'Public',
permissions: [],
},
allowedPrivateProductionSlugs: [TEST_PROJECT_SLUG],
};
export const testProject = { export const testProject = {
id: TEST_PROJECT_ID, id: TEST_PROJECT_ID,
name: 'Playwright Project', name: 'Playwright Project',
@ -58,6 +87,11 @@ export const testProject = {
updatedAt: '2026-01-02T00:00:00.000Z', updatedAt: '2026-01-02T00:00:00.000Z',
}; };
export const testPrivateProject = {
...testProject,
production_presentation_visibility: 'private',
};
const pageElements = [ const pageElements = [
{ {
id: 'nav-next', id: 'nav-next',
@ -175,7 +209,22 @@ const fulfillJson = (route: Route, body: unknown, status = 200) =>
const normalizeApiPath = (url: URL) => url.pathname.replace(/^\/api/, ''); const normalizeApiPath = (url: URL) => url.pathname.replace(/^\/api/, '');
export async function mockFrontendApi(page: Page) { type MockFrontendApiOptions = {
project?: typeof testProject;
user?: TestUser;
requireProductionRuntimeAuth?: boolean;
};
const isAuthorizedRequest = (route: Route) =>
Boolean(route.request().headers().authorization);
export async function mockFrontendApi(
page: Page,
options: MockFrontendApiOptions = {},
) {
const project = options.project ?? testProject;
const user = options.user ?? testUser;
await page.route('**/favicon.ico', (route) => await page.route('**/favicon.ico', (route) =>
route.fulfill({ status: 204, body: '' }), route.fulfill({ status: 204, body: '' }),
); );
@ -202,7 +251,7 @@ export async function mockFrontendApi(page: Page) {
} }
if (path === '/auth/me') { if (path === '/auth/me') {
return fulfillJson(route, testUser); return fulfillJson(route, user);
} }
if (path.endsWith('/count')) { if (path.endsWith('/count')) {
@ -210,20 +259,25 @@ export async function mockFrontendApi(page: Page) {
} }
if (path === `/projects/${TEST_PROJECT_ID}`) { if (path === `/projects/${TEST_PROJECT_ID}`) {
return fulfillJson(route, testProject); return fulfillJson(route, project);
} }
if (path === '/projects/autocomplete') { if (path === '/projects/autocomplete') {
return fulfillJson(route, [ return fulfillJson(route, [{ id: TEST_PROJECT_ID, label: project.name }]);
{ id: TEST_PROJECT_ID, label: testProject.name },
]);
} }
if (path === '/projects') { if (path === '/projects') {
if (url.searchParams.get('slug') === TEST_PROJECT_SLUG) { if (url.searchParams.get('slug') === TEST_PROJECT_SLUG) {
return fulfillJson(route, rowsResponse([testProject])); if (
options.requireProductionRuntimeAuth &&
project.production_presentation_visibility === 'private' &&
!isAuthorizedRequest(route)
) {
return fulfillJson(route, 'Authentication required', 401);
}
return fulfillJson(route, rowsResponse([project]));
} }
return fulfillJson(route, rowsResponse([testProject])); return fulfillJson(route, rowsResponse([project]));
} }
if (path === '/tour_pages') { if (path === '/tour_pages') {
@ -231,6 +285,14 @@ export async function mockFrontendApi(page: Page) {
if (environment === 'dev') { if (environment === 'dev') {
return fulfillJson(route, rowsResponse(testPages)); return fulfillJson(route, rowsResponse(testPages));
} }
if (
options.requireProductionRuntimeAuth &&
request.headers()['x-runtime-environment'] !== 'stage' &&
project.production_presentation_visibility === 'private' &&
!isAuthorizedRequest(route)
) {
return fulfillJson(route, 'Authentication required', 401);
}
const runtimeEnvironment = const runtimeEnvironment =
request.headers()['x-runtime-environment'] === 'stage' request.headers()['x-runtime-environment'] === 'stage'
? 'stage' ? 'stage'
@ -245,6 +307,19 @@ export async function mockFrontendApi(page: Page) {
return fulfillJson(route, rowsResponse(testAssets)); return fulfillJson(route, rowsResponse(testAssets));
} }
if (path.startsWith('/tour_pages/')) {
const id = path.split('/').pop();
if (request.method() === 'PUT') {
const pageToUpdate =
testPages.find((pageItem) => pageItem.id === id) ?? testPages[0];
return fulfillJson(route, {
...pageToUpdate,
...(request.postDataJSON() as { data?: object }).data,
});
}
}
if (path.startsWith('/permissions/')) { if (path.startsWith('/permissions/')) {
const id = path.split('/').pop(); const id = path.split('/').pop();
const permission = testPermissions.find((item) => item.id === id); const permission = testPermissions.find((item) => item.id === id);
@ -316,6 +391,25 @@ export async function mockFrontendApi(page: Page) {
return fulfillJson(route, rowsResponse([])); return fulfillJson(route, rowsResponse([]));
} }
if (path === '/publish/save-to-stage') {
return fulfillJson(route, {
publishEventId: 'publish-event-stage',
status: 'success',
});
}
if (path === '/publish') {
return fulfillJson(route, {
publishEventId: 'publish-event-production',
status: 'success',
summary: {
pages_copied: testPages.length,
transitions_copied: 0,
audios_copied: 0,
},
});
}
if (path === '/file/presign') { if (path === '/file/presign') {
const urls = ((request.postDataJSON() as { urls?: string[] } | null) const urls = ((request.postDataJSON() as { urls?: string[] } | null)
?.urls ?? []) as string[]; ?.urls ?? []) as string[];
@ -335,7 +429,7 @@ export async function mockFrontendApi(page: Page) {
}); });
} }
export async function authenticate(page: Page) { export async function authenticate(page: Page, user = testUser) {
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
window.localStorage.setItem('token', token); window.localStorage.setItem('token', token);

View File

@ -4,15 +4,14 @@ import {
authenticate, authenticate,
collectConsoleFailures, collectConsoleFailures,
mockFrontendApi, mockFrontendApi,
testPrivateProject,
testPrivateRuntimeUser,
} from './fixtures'; } from './fixtures';
test.beforeEach(async ({ page }) => {
await mockFrontendApi(page);
});
test('production presentation loads without authentication', async ({ test('production presentation loads without authentication', async ({
page, page,
}) => { }) => {
await mockFrontendApi(page);
const consoleFailures = collectConsoleFailures(page); const consoleFailures = collectConsoleFailures(page);
await page.goto(`/p/${TEST_PROJECT_SLUG}`); await page.goto(`/p/${TEST_PROJECT_SLUG}`);
@ -33,9 +32,26 @@ test('production presentation loads without authentication', async ({
consoleFailures.assertClean(); consoleFailures.assertClean();
}); });
test('production presentation follows page navigation links', async ({
page,
}) => {
await mockFrontendApi(page);
const consoleFailures = collectConsoleFailures(page);
await page.goto(`/p/${TEST_PROJECT_SLUG}`);
await expect(page.getByText('Next')).toBeVisible();
await page.getByText('Next').click();
await expect(page.getByText('Next')).not.toBeVisible();
await expect(page.getByRole('img', { name: 'Background' })).toBeVisible();
consoleFailures.assertClean();
});
test('stage presentation uses authenticated minimal runtime shell', async ({ test('stage presentation uses authenticated minimal runtime shell', async ({
page, page,
}) => { }) => {
await mockFrontendApi(page);
await authenticate(page); await authenticate(page);
const consoleFailures = collectConsoleFailures(page); const consoleFailures = collectConsoleFailures(page);
@ -51,3 +67,23 @@ test('stage presentation uses authenticated minimal runtime shell', async ({
consoleFailures.assertClean(); consoleFailures.assertClean();
}); });
test('private production presentation loads for a granted viewer', async ({
page,
}) => {
await mockFrontendApi(page, {
project: testPrivateProject,
user: testPrivateRuntimeUser,
requireProductionRuntimeAuth: true,
});
await authenticate(page, testPrivateRuntimeUser);
const consoleFailures = collectConsoleFailures(page);
await page.goto(`/p/${TEST_PROJECT_SLUG}`);
await expect(page).not.toHaveURL(/\/login/);
await expect(page).toHaveTitle(/Playwright Project/);
await expect(page.getByRole('img', { name: 'Background' })).toBeVisible();
consoleFailures.assertClean();
});