added e2e tests
This commit is contained in:
parent
ec908c936d
commit
49917e6406
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user