diff --git a/documentation/project-improvement-todo.ru.md b/documentation/project-improvement-todo.ru.md index ea3da94..dcf5570 100644 --- a/documentation/project-improvement-todo.ru.md +++ b/documentation/project-improvement-todo.ru.md @@ -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 diff --git a/frontend/docs/frontend-architecture.md b/frontend/docs/frontend-architecture.md index 6fe18f4..cfd626a 100644 --- a/frontend/docs/frontend-architecture.md +++ b/frontend/docs/frontend-architecture.md @@ -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 diff --git a/frontend/tests/e2e/app-shell.spec.ts b/frontend/tests/e2e/app-shell.spec.ts index 6832714..c7dfc97 100644 --- a/frontend/tests/e2e/app-shell.spec.ts +++ b/frontend/tests/e2e/app-shell.spec.ts @@ -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(); +}); diff --git a/frontend/tests/e2e/constructor.spec.ts b/frontend/tests/e2e/constructor.spec.ts index 1e18416..26ca18c 100644 --- a/frontend/tests/e2e/constructor.spec.ts +++ b/frontend/tests/e2e/constructor.spec.ts @@ -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, }) => { diff --git a/frontend/tests/e2e/fixtures.ts b/frontend/tests/e2e/fixtures.ts index bee4eb7..e35524d 100644 --- a/frontend/tests/e2e/fixtures.ts +++ b/frontend/tests/e2e/fixtures.ts @@ -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); diff --git a/frontend/tests/e2e/runtime.spec.ts b/frontend/tests/e2e/runtime.spec.ts index 6c35cfe..9eccb0e 100644 --- a/frontend/tests/e2e/runtime.spec.ts +++ b/frontend/tests/e2e/runtime.spec.ts @@ -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(); +});