added e2e tests
This commit is contained in:
parent
ec908c936d
commit
49917e6406
@ -42,19 +42,6 @@ TODO:
|
||||
- Если нужен, оставить `localStorage`, но осознанно и с коротким TTL/refresh policy.
|
||||
- Заменить 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 и зависимости
|
||||
|
||||
### Package manager
|
||||
|
||||
@ -34,8 +34,10 @@ The frontend uses two complementary test layers:
|
||||
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
|
||||
with controlled API fixtures. The E2E suite intentionally stays compact:
|
||||
auth redirects/login, authenticated shell routes, constructor canvas/toolbar
|
||||
responsiveness, and stage/production runtime smoke.
|
||||
auth redirects/login, authenticated shell routes, REST-backed list/filter/edit
|
||||
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
|
||||
network boundary. This keeps tests deterministic while still exercising real
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
TEST_PROJECT_ID,
|
||||
authenticate,
|
||||
collectConsoleFailures,
|
||||
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(/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();
|
||||
});
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
TEST_PROJECT_ID,
|
||||
TEST_PAGE_ONE_ID,
|
||||
authenticate,
|
||||
collectConsoleFailures,
|
||||
mockFrontendApi,
|
||||
@ -50,6 +51,55 @@ test('constructor loads project canvas, assets, and core controls', async ({
|
||||
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 ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@ -32,7 +32,26 @@ export const testPermissions = [
|
||||
{ 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',
|
||||
email: 'admin@example.test',
|
||||
firstName: 'Playwright',
|
||||
@ -46,6 +65,16 @@ export const testUser = {
|
||||
allowedPrivateProductionSlugs: [],
|
||||
};
|
||||
|
||||
export const testPrivateRuntimeUser = {
|
||||
...testUser,
|
||||
app_role: {
|
||||
id: 'role-public',
|
||||
name: 'Public',
|
||||
permissions: [],
|
||||
},
|
||||
allowedPrivateProductionSlugs: [TEST_PROJECT_SLUG],
|
||||
};
|
||||
|
||||
export const testProject = {
|
||||
id: TEST_PROJECT_ID,
|
||||
name: 'Playwright Project',
|
||||
@ -58,6 +87,11 @@ export const testProject = {
|
||||
updatedAt: '2026-01-02T00:00:00.000Z',
|
||||
};
|
||||
|
||||
export const testPrivateProject = {
|
||||
...testProject,
|
||||
production_presentation_visibility: 'private',
|
||||
};
|
||||
|
||||
const pageElements = [
|
||||
{
|
||||
id: 'nav-next',
|
||||
@ -175,7 +209,22 @@ const fulfillJson = (route: Route, body: unknown, status = 200) =>
|
||||
|
||||
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) =>
|
||||
route.fulfill({ status: 204, body: '' }),
|
||||
);
|
||||
@ -202,7 +251,7 @@ export async function mockFrontendApi(page: Page) {
|
||||
}
|
||||
|
||||
if (path === '/auth/me') {
|
||||
return fulfillJson(route, testUser);
|
||||
return fulfillJson(route, user);
|
||||
}
|
||||
|
||||
if (path.endsWith('/count')) {
|
||||
@ -210,20 +259,25 @@ export async function mockFrontendApi(page: Page) {
|
||||
}
|
||||
|
||||
if (path === `/projects/${TEST_PROJECT_ID}`) {
|
||||
return fulfillJson(route, testProject);
|
||||
return fulfillJson(route, project);
|
||||
}
|
||||
|
||||
if (path === '/projects/autocomplete') {
|
||||
return fulfillJson(route, [
|
||||
{ id: TEST_PROJECT_ID, label: testProject.name },
|
||||
]);
|
||||
return fulfillJson(route, [{ id: TEST_PROJECT_ID, label: project.name }]);
|
||||
}
|
||||
|
||||
if (path === '/projects') {
|
||||
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') {
|
||||
@ -231,6 +285,14 @@ export async function mockFrontendApi(page: Page) {
|
||||
if (environment === 'dev') {
|
||||
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 =
|
||||
request.headers()['x-runtime-environment'] === 'stage'
|
||||
? 'stage'
|
||||
@ -245,6 +307,19 @@ export async function mockFrontendApi(page: Page) {
|
||||
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/')) {
|
||||
const id = path.split('/').pop();
|
||||
const permission = testPermissions.find((item) => item.id === id);
|
||||
@ -316,6 +391,25 @@ export async function mockFrontendApi(page: Page) {
|
||||
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') {
|
||||
const urls = ((request.postDataJSON() as { urls?: string[] } | null)
|
||||
?.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(
|
||||
({ token, user }) => {
|
||||
window.localStorage.setItem('token', token);
|
||||
|
||||
@ -4,15 +4,14 @@ import {
|
||||
authenticate,
|
||||
collectConsoleFailures,
|
||||
mockFrontendApi,
|
||||
testPrivateProject,
|
||||
testPrivateRuntimeUser,
|
||||
} from './fixtures';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockFrontendApi(page);
|
||||
});
|
||||
|
||||
test('production presentation loads without authentication', async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockFrontendApi(page);
|
||||
const consoleFailures = collectConsoleFailures(page);
|
||||
|
||||
await page.goto(`/p/${TEST_PROJECT_SLUG}`);
|
||||
@ -33,9 +32,26 @@ test('production presentation loads without authentication', async ({
|
||||
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 ({
|
||||
page,
|
||||
}) => {
|
||||
await mockFrontendApi(page);
|
||||
await authenticate(page);
|
||||
const consoleFailures = collectConsoleFailures(page);
|
||||
|
||||
@ -51,3 +67,23 @@ test('stage presentation uses authenticated minimal runtime shell', async ({
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user