From 25736436c246e1cae11ee0d4c415724ff1105133 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:09:45 +0900 Subject: [PATCH] refactor --- .github/scripts/chrome.mts | 56 +------- .../measure-frontend-browser-comparison.mts | 30 +--- packages/frontend/test/e2e/basic.spec.ts | 12 +- packages/frontend/test/e2e/router.spec.ts | 7 +- packages/frontend/test/e2e/shared.ts | 135 ++++++++++++++++++ packages/frontend/test/e2e/utils.ts | 94 ++++-------- 6 files changed, 172 insertions(+), 162 deletions(-) create mode 100644 packages/frontend/test/e2e/shared.ts diff --git a/.github/scripts/chrome.mts b/.github/scripts/chrome.mts index e5fa46cde9..5d07ac6efe 100644 --- a/.github/scripts/chrome.mts +++ b/.github/scripts/chrome.mts @@ -5,15 +5,9 @@ import { createRequire } from 'node:module'; import { writeFile } from 'node:fs/promises'; -import * as util from './utility.mts'; -import type { Locator, Browser, BrowserContext, CDPSession, Page } from 'playwright'; +import type { Browser, BrowserContext, CDPSession, Page } from 'playwright'; import type { HeapSnapshotData } from './heap-snapshot-util.mts'; -type LocatorEntry = { - name: string; - locator: Locator; -}; - export type NetworkRequest = { requestId: string; url: string; @@ -449,16 +443,6 @@ export class HeadlessChromeController { return JSON.parse(content); } - public mkInput(testId: string) { - return this.page.locator(`[data-testid="${testId}"] input`); - } - - public waitApiResponse(path: string) { - return this.page.waitForResponse((response) => { - return response.url().endsWith(path) && response.request().method() === 'POST'; - }); - } - public async close() { await this.cdp.detach().catch(() => undefined); await this.context.close().catch(() => undefined); @@ -537,41 +521,3 @@ export function summarizeNetwork(requestRows: NetworkRequest[], baseUrl: string, })), }; } - -export async function isReady(locator: Locator, options: { visible?: boolean; enabled?: boolean }) { - const first = locator.first(); - try { - if (await locator.count() === 0) return false; - if (options.visible === true && !await first.isVisible()) return false; - if (options.enabled === true && !await first.isEnabled()) return false; - return true; - } catch { - return false; - } -} - -export async function waitForReady(locator: Locator, options: { timeoutMs: number; visible?: boolean; enabled?: boolean }) { - const startedAt = Date.now(); - while (Date.now() - startedAt < options.timeoutMs) { - if (await isReady(locator, options)) return true; - await util.sleep(250); - } - return false; -} - -export async function waitForAnyLocator(entries: LocatorEntry[], options: { timeoutMs: number; visible?: boolean; enabled?: boolean }) { - const startedAt = Date.now(); - while (Date.now() - startedAt < options.timeoutMs) { - for (const entry of entries) { - if (await isReady(entry.locator, options)) return entry.name; - } - await util.sleep(250); - } - return null; -} - -export async function maybeClick(locator: Locator, timeoutMs = 3_000) { - if (!await waitForReady(locator, { visible: true, enabled: true, timeoutMs })) return false; - await locator.click({ timeout: timeoutMs }); - return true; -} diff --git a/.github/scripts/measure-frontend-browser-comparison.mts b/.github/scripts/measure-frontend-browser-comparison.mts index e8b22e185c..0825133b1d 100644 --- a/.github/scripts/measure-frontend-browser-comparison.mts +++ b/.github/scripts/measure-frontend-browser-comparison.mts @@ -9,6 +9,7 @@ import * as util from './utility.mts'; import * as heapSnapshotUtil from './heap-snapshot-util.mts'; import { HeadlessChromeController, summarizeNetwork } from './chrome.mts'; import type { BrowserMeasurement, NetworkRequest, NetworkSummary } from './chrome.mts'; +import { closeUserSetupDialog, postNote, signupThroughUi, visitHome } from '../../packages/frontend/test/e2e/shared.ts'; const [baseDirArg, headDirArg, baseOutputArg, headOutputArg, headHeapSnapshotOutputArg] = process.argv.slice(2); @@ -37,31 +38,10 @@ async function runSignupAndPostScenario(chrome: HeadlessChromeController) { const page = chrome.page; const noteText = `Frontend browser metrics ${Date.now()}`; - await page.goto(`${baseUrl}/`, { waitUntil: 'domcontentloaded' }); - - await page.getByTestId('signup').click(); - await page.getByTestId('signup-rules-continue').waitFor({ state: 'visible' }); - await chrome.page.locator('[data-testid="signup-rules-notes-agree"] [data-testid="switch-toggle"]').click(); - await page.getByTestId('modal-dialog-ok').click(); - await page.getByTestId('signup-rules-continue').click(); - - await chrome.mkInput('signup-username').fill('alice'); - await chrome.mkInput('signup-password').fill('password'); - await chrome.mkInput('signup-password-retype').fill('password'); - await chrome.mkInput('signup-invitation-code').fill('test-invitation-code'); - - const signupResponse = chrome.waitApiResponse('/api/signup'); - await page.getByTestId('signup-submit').click(); - await signupResponse; - - await page.locator('[data-testid="user-setup-dialog"] [data-testid="modal-window-close"]').click({ timeout: 30000 }); - await page.getByTestId('modal-dialog-ok').click(); - - await page.getByTestId('open-post-form').waitFor({ state: 'visible' }); - await page.getByTestId('open-post-form').click(); - await page.getByTestId('post-form-text').fill(noteText); - await page.getByTestId('post-form-submit').click(); - await page.getByText(noteText).waitFor({ timeout: 10000 }); + await visitHome(page, baseUrl); + await signupThroughUi(page, { username: 'alice', password: 'password' }); + await closeUserSetupDialog(page); + await postNote(page, noteText, 10_000); await util.sleep(1000); } diff --git a/packages/frontend/test/e2e/basic.spec.ts b/packages/frontend/test/e2e/basic.spec.ts index ceae8c6c25..d432680e1d 100644 --- a/packages/frontend/test/e2e/basic.spec.ts +++ b/packages/frontend/test/e2e/basic.spec.ts @@ -10,7 +10,7 @@ import { // locator helper locateMkInput, locateMkSwitch, locateMkTextarea, // utils - registerUser, resetState, visitHome, + registerUser, resetState, visitHome, closeUserSetupDialog, postNote, // page utils waitApiResponse, signIn, } from './utils.js'; @@ -194,17 +194,11 @@ test.describe('After user setup', () => { await signIn(page, 'alice', 'alice1234'); // 表示に時間がかかるのでデフォルト秒数だとタイムアウトする - await page.locator('[data-testid="user-setup-dialog"] [data-testid="modal-window-close"]').click({ timeout: 30000 }); - await page.getByTestId('modal-dialog-ok').click(); + await closeUserSetupDialog(page); }); test('note', async ({ page }) => { - await page.getByTestId('open-post-form').waitFor({ state: 'visible' }); - await page.getByTestId('open-post-form').click(); - await page.getByTestId('post-form-text').fill('Hello, Misskey!'); - await page.getByTestId('post-form-submit').click(); - - await page.getByText('Hello, Misskey!').waitFor({ timeout: 15000 }); + await postNote(page, 'Hello, Misskey!'); }); test('open note form with hotkey', async ({ page }) => { diff --git a/packages/frontend/test/e2e/router.spec.ts b/packages/frontend/test/e2e/router.spec.ts index 83754d1fb9..41c057b510 100644 --- a/packages/frontend/test/e2e/router.spec.ts +++ b/packages/frontend/test/e2e/router.spec.ts @@ -10,7 +10,7 @@ import { // utils resetState, registerUser, // page utils - signIn, + signIn, closeUserSetupDialogIfVisible, } from './utils.js'; test.describe('Router transition', () => { @@ -26,10 +26,7 @@ test.describe('Router transition', () => { // 表示に時間がかかるのでデフォルト秒数だとタイムアウトする。少し待つ await page.waitForTimeout(1000); - if (await page.getByTestId('user-setup-dialog').isVisible()) { - await page.locator('[data-testid="user-setup-dialog"] [data-testid="modal-window-close"]').click(); - await page.getByTestId('modal-dialog-ok').click(); - } + await closeUserSetupDialogIfVisible(page); }); test.describe('Redirect', () => { diff --git a/packages/frontend/test/e2e/shared.ts b/packages/frontend/test/e2e/shared.ts new file mode 100644 index 0000000000..2bff5bf136 --- /dev/null +++ b/packages/frontend/test/e2e/shared.ts @@ -0,0 +1,135 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { Locator, Page } from 'playwright'; + +export const ADMIN_SETUP_PASSWORD = 'example_password_please_change_this_or_you_will_get_hacked'; +export const DEFAULT_INVITATION_CODE = 'test-invitation-code'; + +export interface RegisteredUser { + id: string; + token: string; +} + +export function assertOk(status: number, route: string): void { + if (status < 200 || status >= 300) { + throw new Error(`${route} failed: status=${status}`); + } +} + +export async function api(baseUrl: string, endpoint: string, body: Record) { + const response = await fetch(`${baseUrl}/api/${endpoint}`, { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + }, + }); + assertOk(response.status, `/api/${endpoint}`); + if (response.status === 204) return null; + return await response.json(); +} + +export async function resetState(baseUrl: string): Promise { + await api(baseUrl, 'reset-db', {}); +} + +export async function registerUser( + baseUrl: string, + username: string, + password: string, + isAdmin = false, +): Promise { + const route = isAdmin ? 'admin/accounts/create' : 'signup'; + const result = await api(baseUrl, route, { + username, + password, + ...(isAdmin ? { setupPassword: ADMIN_SETUP_PASSWORD } : {}), + }); + return result as RegisteredUser; +} + +export function locateMkInput(page: Page, testId: string): Locator { + return page.locator(`[data-testid="${testId}"] input`); +} + +export function locateMkTextarea(page: Page, testId: string): Locator { + return page.locator(`[data-testid="${testId}"] textarea`); +} + +export function locateMkSwitch(page: Page, testId: string): Locator { + return page.locator(`[data-testid="${testId}"] [data-testid="switch-toggle"]`); +} + +export async function visitHome(page: Page, baseUrl: string): Promise { + await page.goto(`${baseUrl}/`); + await page.locator('button').first().waitFor({ state: 'visible', timeout: 30_000 }); +} + +export async function waitApiResponse(page: Page, path: string, timeout = 30_000): Promise { + await page.waitForResponse((response) => { + return response.url().endsWith(path) && response.request().method() === 'POST'; + }, { timeout }); +} + +export async function signIn(page: Page, baseUrl: string, username: string, password: string): Promise { + await visitHome(page, baseUrl); + await page.getByTestId('signin').click(); + await page.getByTestId('signin-page-input').waitFor({ state: 'visible', timeout: 10_000 }); + await locateMkInput(page, 'signin-username').fill(username); + await page.keyboard.press('Enter'); + await page.getByTestId('signin-page-password').waitFor({ state: 'visible', timeout: 10_000 }); + await locateMkInput(page, 'signin-password').fill(password); + const signinResponse = waitApiResponse(page, '/api/signin-flow'); + await page.keyboard.press('Enter'); + await signinResponse; +} + +export async function acceptSignupRules(page: Page): Promise { + await page.getByTestId('signup-rules-continue').waitFor({ state: 'visible' }); + await locateMkSwitch(page, 'signup-rules-notes-agree').click(); + await page.getByTestId('modal-dialog-ok').click(); + await page.getByTestId('signup-rules-continue').click(); +} + +export async function signupThroughUi( + page: Page, + options: { + username: string; + password: string; + invitationCode?: string; + }, +): Promise { + await page.getByTestId('signup').click(); + await acceptSignupRules(page); + + await locateMkInput(page, 'signup-username').fill(options.username); + await locateMkInput(page, 'signup-password').fill(options.password); + await locateMkInput(page, 'signup-password-retype').fill(options.password); + await locateMkInput(page, 'signup-invitation-code').fill(options.invitationCode ?? DEFAULT_INVITATION_CODE); + + const signupResponse = waitApiResponse(page, '/api/signup'); + await page.getByTestId('signup-submit').click(); + await signupResponse; +} + +export async function closeUserSetupDialog(page: Page, timeout = 30_000): Promise { + await page.locator('[data-testid="user-setup-dialog"] [data-testid="modal-window-close"]').click({ timeout }); + await page.getByTestId('modal-dialog-ok').click(); +} + +export async function closeUserSetupDialogIfVisible(page: Page): Promise { + if (await page.getByTestId('user-setup-dialog').isVisible()) { + await closeUserSetupDialog(page); + } +} + +export async function postNote(page: Page, noteText: string, timeout = 15_000): Promise { + await page.getByTestId('open-post-form').waitFor({ state: 'visible' }); + await page.getByTestId('open-post-form').click(); + await page.getByTestId('post-form-text').fill(noteText); + await page.getByTestId('post-form-submit').click(); + await page.getByText(noteText).waitFor({ timeout }); +} diff --git a/packages/frontend/test/e2e/utils.ts b/packages/frontend/test/e2e/utils.ts index 4a21076464..67bcfd10d1 100644 --- a/packages/frontend/test/e2e/utils.ts +++ b/packages/frontend/test/e2e/utils.ts @@ -3,92 +3,50 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import type { Locator, Page } from 'playwright'; +import type { Page } from 'playwright'; +import { + registerUser as registerUserWithBaseUrl, + resetState as resetStateWithBaseUrl, + signIn as signInWithBaseUrl, + visitHome as visitHomeWithBaseUrl, +} from './shared.js'; +export type { RegisteredUser } from './shared.js'; +export { + ADMIN_SETUP_PASSWORD, + DEFAULT_INVITATION_CODE, + acceptSignupRules, + assertOk, + closeUserSetupDialog, + closeUserSetupDialogIfVisible, + locateMkInput, + locateMkSwitch, + locateMkTextarea, + postNote, + waitApiResponse, +} from './shared.js'; export const BASE_URL = 'http://localhost:61812'; -export const ADMIN_SETUP_PASSWORD = 'example_password_please_change_this_or_you_will_get_hacked'; - -export interface RegisteredUser { - id: string; - token: string; -} //#region Misc -export function assertOk(status: number, route: string): void { - if (status < 200 || status >= 300) { - throw new Error(`${route} failed: status=${status}`); - } -} - export async function resetState(): Promise { - const response = await fetch(`${BASE_URL}/api/reset-db`, { - method: 'POST', - body: '{}', - headers: { - 'Content-Type': 'application/json', - }, - }); - assertOk(response.status, '/api/reset-db'); + await resetStateWithBaseUrl(BASE_URL); } export async function registerUser( username: string, password: string, isAdmin = false, -): Promise { - const route = isAdmin ? '/api/admin/accounts/create' : '/api/signup'; - const response = await fetch(`${BASE_URL}${route}`, { - method: 'POST', - body: JSON.stringify({ - username, - password, - ...(isAdmin ? { setupPassword: ADMIN_SETUP_PASSWORD } : {}), - }), - headers: { - 'Content-Type': 'application/json', - }, - }); - assertOk(response.status, route); - return await response.json() as RegisteredUser; -} -//#endregion - -//#region Locator Helpers -export function locateMkInput(page: Page, testId: string): Locator { - return page.locator(`[data-testid="${testId}"] input`); -} - -export function locateMkTextarea(page: Page, testId: string): Locator { - return page.locator(`[data-testid="${testId}"] textarea`); -} - -export function locateMkSwitch(page: Page, testId: string): Locator { - return page.locator(`[data-testid="${testId}"] [data-testid="switch-toggle"]`); +): ReturnType { + return registerUserWithBaseUrl(BASE_URL, username, password, isAdmin); } //#endregion //#region Page Helpers export async function visitHome(page: Page): Promise { - await page.goto(`${BASE_URL}/`); - await page.locator('button').first().waitFor({ state: 'visible', timeout: 30_000 }); -} - -export async function waitApiResponse(page: Page, path: string): Promise { - await page.waitForResponse((response) => { - return response.url().endsWith(path) && response.request().method() === 'POST'; - }, { timeout: 30_000 }); + await visitHomeWithBaseUrl(page, BASE_URL); } export async function signIn(page: Page, username: string, password: string): Promise { - await visitHome(page); - await page.getByTestId('signin').click(); - await page.getByTestId('signin-page-input').waitFor({ state: 'visible', timeout: 10_000 }); - await locateMkInput(page, 'signin-username').fill(username); - await page.keyboard.press('Enter'); - await page.getByTestId('signin-page-password').waitFor({ state: 'visible', timeout: 10_000 }); - await locateMkInput(page, 'signin-password').fill(password); - const signinResponse = waitApiResponse(page, '/api/signin-flow'); - await page.keyboard.press('Enter'); - await signinResponse; + await signInWithBaseUrl(page, BASE_URL, username, password); } //#endregion