diff --git a/.config/cypress-devcontainer.yml b/.config/playwright-devcontainer.yml similarity index 100% rename from .config/cypress-devcontainer.yml rename to .config/playwright-devcontainer.yml diff --git a/.github/workflows/test-frontend.yml b/.github/workflows/test-frontend.yml index 224035d27d..e8a98b2b78 100644 --- a/.github/workflows/test-frontend.yml +++ b/.github/workflows/test-frontend.yml @@ -89,11 +89,11 @@ jobs: - name: Playwright install working-directory: packages/frontend run: pnpm exec playwright install --with-deps chromium - - name: Vitest Browser E2E run + - name: Test run: pnpm start-server-and-test start:test http://localhost:61812 "pnpm --filter frontend test:e2e" timeout-minutes: 15 - uses: actions/upload-artifact@v7 if: failure() with: - name: vitest-browser-screenshots - path: packages/frontend/test/e2e/screenshots + name: playwright-e2e-artifacts + path: packages/frontend/test/e2e/artifacts diff --git a/.gitignore b/.gitignore index 6f6d674bf8..b03f1ee637 100644 --- a/.gitignore +++ b/.gitignore @@ -34,7 +34,7 @@ coverage !/.config/example.yml !/.config/docker_example.yml !/.config/docker_example.env -!/.config/cypress-devcontainer.yml +!/.config/playwright-devcontainer.yml docker-compose.yml ./compose.yml .devcontainer/compose.yml diff --git a/package.json b/package.json index cf02c92c79..c90030f3a5 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "check-dts": "node scripts/check-dts.mjs", "lint": "pnpm --no-bail -r lint && pnpm check-dts", "e2e": "pnpm start-server-and-test start:test http://localhost:61812 \"pnpm --filter frontend test:e2e\"", - "e2e-dev-container": "ncp ./.config/cypress-devcontainer.yml ./.config/test.yml && pnpm start-server-and-test start:test http://localhost:61812 \"pnpm --filter frontend test:e2e\"", + "e2e-dev-container": "ncp ./.config/playwright-devcontainer.yml ./.config/test.yml && pnpm start-server-and-test start:test http://localhost:61812 \"pnpm --filter frontend test:e2e\"", "backend-unit-test": "cd packages/backend && pnpm test", "backend-unit-test-and-coverage": "cd packages/backend && pnpm test-and-coverage", "test": "pnpm -r test", diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 70824b7593..3cf135bae1 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -11,7 +11,8 @@ "chromatic": "chromatic", "test": "vitest --run --globals --config vitest.config.unit.ts", "test-and-coverage": "vitest --run --coverage --globals --config vitest.config.unit.ts", - "test:e2e": "vitest --run --globals --config vitest.config.e2e.ts", + "test:e2e": "playwright test --config playwright.config.ts", + "test:browser": "vitest --run --globals --config vitest.config.browser.ts", "typecheck": "vue-tsc --noEmit", "eslint": "eslint --quiet \"src/**/*.{ts,vue}\"", "lint": "pnpm typecheck && pnpm eslint" @@ -72,6 +73,7 @@ "devDependencies": { "@misskey-dev/emoji-assets": "17.0.3", "@misskey-dev/summaly": "5.5.1", + "@playwright/test": "1.61.1", "@rollup/plugin-json": "6.1.0", "@rollup/pluginutils": "5.4.0", "@storybook/addon-essentials": "8.6.18", @@ -103,9 +105,9 @@ "@types/textarea-caret": "3.0.4", "@types/throttle-debounce": "5.0.2", "@types/tinycolor2": "1.4.6", - "@vitest/browser-playwright": "4.1.9", "@typescript-eslint/eslint-plugin": "8.62.0", "@typescript-eslint/parser": "8.62.0", + "@vitest/browser-playwright": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vue/compiler-core": "3.5.39", "astring": "1.9.0", diff --git a/packages/frontend/playwright.config.ts b/packages/frontend/playwright.config.ts new file mode 100644 index 0000000000..ca3a5123e1 --- /dev/null +++ b/packages/frontend/playwright.config.ts @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './test/e2e', + fullyParallel: false, + workers: 1, + timeout: 60_000, + expect: { + timeout: 10_000, + }, + outputDir: './test/e2e/artifacts', + use: { + locale: 'en-US', + baseURL: 'http://localhost:61812', + headless: true, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + video: 'off', + }, + projects: [{ + name: 'chromium', + use: { + browserName: 'chromium', + }, + }], +}); diff --git a/packages/frontend/test/e2e/basic.browser.test.ts b/packages/frontend/test/e2e/basic.browser.test.ts deleted file mode 100644 index 55846c9455..0000000000 --- a/packages/frontend/test/e2e/basic.browser.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { commands } from 'vitest/browser'; -import { describe, it } from 'vitest'; - -describe('Before setup instance', () => { - it('successfully loads', async () => { - await commands.runScenario('before-setup-loads'); - }); - - it('setup instance', async () => { - await commands.runScenario('setup-instance'); - }); -}); - -describe('After setup instance', () => { - it('successfully loads', async () => { - await commands.runScenario('after-setup-loads'); - }); - - it('signup', async () => { - await commands.runScenario('signup'); - }); - - it('signup with duplicated username', async () => { - await commands.runScenario('signup-duplicated'); - }); -}); - -describe('After user signup', () => { - it('signin', async () => { - await commands.runScenario('signin'); - }); - - it('suspend', async () => { - await commands.runScenario('suspend'); - }); -}); - -describe('After user signed in', () => { - it('successfully loads', async () => { - await commands.runScenario('after-signed-in-loads'); - }); - - it('account setup wizard', async () => { - await commands.runScenario('account-setup-wizard'); - }); -}); - -describe('After user setup', () => { - it('note', async () => { - await commands.runScenario('note'); - }); - - it('open note form with hotkey', async () => { - await commands.runScenario('open-note-form-with-hotkey'); - }); -}); diff --git a/packages/frontend/test/e2e/basic.spec.ts b/packages/frontend/test/e2e/basic.spec.ts new file mode 100644 index 0000000000..7544eb9c0c --- /dev/null +++ b/packages/frontend/test/e2e/basic.spec.ts @@ -0,0 +1,219 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { test } from './fixtures.js'; +import { + // const + ADMIN_SETUP_PASSWORD, BASE_URL, + // locator helper + locateMkInput, locateMkSwitch, locateMkTextarea, + // utils + registerUser, waitApiResponse, resetState, visitHome, signIn, +} from './utils.js'; +import type { RegisteredUser } from './utils.js'; + +test.describe('Before setup instance', () => { + test.beforeEach(async ({ request }) => { + await resetState(request); + }); + + test('successfully loads', async ({ page }) => { + await visitHome(page); + }); + + test('setup instance', async ({ page }) => { + await visitHome(page); + + await locateMkInput(page, 'admin-initial-password').fill(ADMIN_SETUP_PASSWORD); + await locateMkInput(page, 'admin-username').fill('admin'); + await locateMkInput(page, 'admin-password').fill('admin1234'); + + const signupResponse = waitApiResponse(page, '/api/admin/accounts/create'); + await page.getByTestId('admin-ok').click(); + await signupResponse; + + await page.getByTestId('next').click(); + await locateMkInput(page, 'server-setup-server-name').fill('Testskey'); + const updateMetaResponse = waitApiResponse(page, '/api/admin/update-meta'); + await page.getByTestId('server-setup-wizard-apply').click(); + await updateMetaResponse; + }); +}); + +test.describe('After setup instance', () => { + test.beforeEach(async ({ request }) => { + await resetState(request); + await registerUser(request, 'admin', 'pass', true); + }); + + test('successfully loads', async ({ page }) => { + await visitHome(page); + }); + + test('signup', async ({ page }) => { + await visitHome(page); + + await page.getByTestId('signup').click(); + await page.getByTestId('signup-rules-continue').waitFor({ state: 'visible' }); + test.expect(await page.getByTestId('signup-rules-continue').isDisabled()).toBeTruthy(); + + await locateMkSwitch(page, 'signup-rules-notes-agree').click(); + await page.getByTestId('modal-dialog-ok').click(); + await page.getByTestId('signup-rules-continue').click(); + + test.expect(await page.getByTestId('signup-submit').isDisabled()).toBeTruthy(); + await locateMkInput(page, 'signup-username').fill('alice'); + test.expect(await page.getByTestId('signup-submit').isDisabled()).toBeTruthy(); + await locateMkInput(page, 'signup-password').fill('alice1234'); + test.expect(await page.getByTestId('signup-submit').isDisabled()).toBeTruthy(); + await locateMkInput(page, 'signup-password-retype').fill('alice1234'); + test.expect(await page.getByTestId('signup-submit').isDisabled()).toBeTruthy(); + await locateMkInput(page, 'signup-invitation-code').fill('test-invitation-code'); + test.expect(await page.getByTestId('signup-submit').isDisabled()).toBeFalsy(); + + const signupResponse = waitApiResponse(page, '/api/signup'); + await page.getByTestId('signup-submit').click(); + await signupResponse; + }); + + test('signup with duplicated username', async ({ page }) => { + await registerUser(page.request, 'alice', 'alice1234'); + await visitHome(page); + + await page.getByTestId('signup').click(); + await locateMkSwitch(page, 'signup-rules-notes-agree').click(); + await page.getByTestId('modal-dialog-ok').click(); + test.expect(await page.getByTestId('signup-rules-continue').isDisabled()).toBeFalsy(); + await page.getByTestId('signup-rules-continue').click(); + + await locateMkInput(page, 'signup-username').fill('alice'); + await locateMkInput(page, 'signup-password').fill('alice1234'); + await locateMkInput(page, 'signup-password-retype').fill('alice1234'); + test.expect(await page.getByTestId('signup-submit').isDisabled()).toBeTruthy(); + }); +}); + +test.describe('After user signup', () => { + let admin: RegisteredUser; + let alice: RegisteredUser; + + test.beforeEach(async ({ request }) => { + await resetState(request); + admin = await registerUser(request, 'admin', 'pass', true); + alice = await registerUser(request, 'alice', 'alice1234'); + }); + + test('successfully loads', async ({ page }) => { + await visitHome(page); + }); + + test('signin', async ({ page }) => { + await visitHome(page); + + await page.getByTestId('signin').click(); + + await page.getByTestId('signin-page-input').waitFor({ state: 'visible', timeout: 10000 }); + await locateMkInput(page, 'signin-username').fill('alice'); + // Enterキーで続行できるかどうかの確認も兼ねる + await page.keyboard.press('Enter'); + + await page.getByTestId('signin-page-password').waitFor({ state: 'visible', timeout: 10000 }); + await locateMkInput(page, 'signin-password').fill('alice1234'); + + const signinResponse = waitApiResponse(page, '/api/signin-flow'); + // Enterキーで続行できるかどうかの確認も兼ねる + await page.keyboard.press('Enter'); + await signinResponse; + }); + + test('suspend', async ({ page }) => { + await page.request.post(`${BASE_URL}/api/admin/suspend-user`, { + data: { + i: admin.token, + userId: alice.id, + }, + }); + + await visitHome(page); + + await page.getByTestId('signin').click(); + + await page.getByTestId('signin-page-input').waitFor({ state: 'visible', timeout: 10000 }); + await locateMkInput(page, 'signin-username').fill('alice'); + await page.keyboard.press('Enter'); + + await page.getByText('This account has been suspended due to').waitFor({ timeout: 10000 }); + }); +}); + +test.describe('After user signed in', () => { + test.beforeEach(async ({ request, page }) => { + await resetState(request); + await registerUser(request, 'admin', 'pass', true); + await registerUser(request, 'alice', 'alice1234'); + await signIn(page, 'alice', 'alice1234'); + }); + + test('successfully loads', async ({ page }) => { + // 表示に時間がかかるのでデフォルト秒数だとタイムアウトする + await page.getByTestId('user-setup-continue').waitFor({ state: 'visible', timeout: 30000 }); + }); + + test('account setup wizard', async ({ page }) => { + // 表示に時間がかかるのでデフォルト秒数だとタイムアウトする + await page.getByTestId('user-setup-continue').click({ timeout: 30000 }); + + await locateMkInput(page, 'user-setup-user-name').fill('ありす'); + await locateMkTextarea(page, 'user-setup-user-description').fill('ほげ'); + // TODO: アイコン設定のテスト + await page.getByTestId('user-setup-continue').click(); + + // プライバシー設定 + await page.getByTestId('user-setup-continue').click(); + + // フォロー設定 + await page.getByTestId('user-setup-continue').click(); + + // プッシュ通知設定 + await page.getByTestId('user-setup-continue').click(); + + // 完了 + await page.getByTestId('user-setup-continue').click(); + }); +}); + +test.describe('After user setup', () => { + test.beforeEach(async ({ request, page }) => { + await resetState(request); + await registerUser(request, 'admin', 'pass', true); + await registerUser(request, 'alice', 'alice1234'); + 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(); + }); + + 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 }); + }); + + test('open note form with hotkey', async ({ page }) => { + await page.getByTestId('open-post-form').waitFor({ state: 'visible' }); + await page.keyboard.press('KeyN'); + await page.getByTestId('post-form-text').waitFor({ state: 'visible' }); + await page.keyboard.press('Escape'); + await page.getByTestId('post-form-text').waitFor({ state: 'hidden' }); + }); +}); + +// TODO: 投稿フォームの公開範囲指定のテスト +// TODO: 投稿フォームのファイル添付のテスト +// TODO: 投稿フォームのハッシュタグ保持フィールドのテスト diff --git a/packages/frontend/test/e2e/browser-commands.ts b/packages/frontend/test/e2e/browser-commands.ts deleted file mode 100644 index 01d8fca94a..0000000000 --- a/packages/frontend/test/e2e/browser-commands.ts +++ /dev/null @@ -1,279 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { APIRequestContext, Page, Locator } from 'playwright'; -import type { BrowserCommand } from 'vitest/node'; -import '@vitest/browser-playwright'; - -const BASE_URL = 'http://localhost:61812'; -const ADMIN_SETUP_PASSWORD = 'example_password_please_change_this_or_you_will_get_hacked'; - -interface RegisteredUser { - id: string; - token: string; -} - -async function assertOk(status: number, body: string, route: string): Promise { - if (status >= 200 && status < 300) return; - throw new Error(`${route} failed: status=${status} body=${body}`); -} - -async function resetState(request: APIRequestContext): Promise { - const response = await request.post(`${BASE_URL}/api/reset-db`, { - data: '{}', - headers: { - 'Content-Type': 'application/json', - }, - }); - await assertOk(response.status(), await response.text(), '/api/reset-db'); -} - -function locateMkInput(page: Page, testId: string): Locator { - return page.locator(`[data-testid="${testId}"] input`); -} - -function locateMkTextarea(page: Page, testId: string): Locator { - return page.locator(`[data-testid="${testId}"] textarea`); -} - -function locateMkSwitch(page: Page, testId: string): Locator { - return page.locator(`[data-testid="${testId}"] [data-testid="switch-toggle"]`); -} - -async function registerUser( - request: APIRequestContext, - username: string, - password: string, - isAdmin = false, -): Promise { - const route = isAdmin ? '/api/admin/accounts/create' : '/api/signup'; - const response = await request.post(`${BASE_URL}${route}`, { - data: { - username, - password, - ...(isAdmin ? { setupPassword: ADMIN_SETUP_PASSWORD } : {}), - }, - }); - await assertOk(response.status(), await response.text(), route); - return await response.json() as RegisteredUser; -} - -async function waitApiResponse(page: Page, path: string): Promise { - await page.waitForResponse((response) => { - return response.url().endsWith(path) && response.request().method() === 'POST'; - }, { timeout: 30_000 }); -} - -async function visitHome(page: Page): Promise { - await page.goto(`${BASE_URL}/`); - await page.locator('button').first().waitFor({ state: 'visible', timeout: 30_000 }); -} - -async function signInFromTopPage(page: Page, username: string, password: string): Promise { - 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; -} - -async function dismissAccountSetupWizard(page: Page): Promise { - await page.locator('[data-testid="user-setup-dialog"] [data-testid="modal-window-close"]').click({ timeout: 30_000 }); - await page.getByTestId('modal-dialog-ok').click(); -} - -async function prepareAfterSetup(request: APIRequestContext): Promise { - await resetState(request); - await registerUser(request, 'admin', 'pass', true); -} - -async function prepareAfterSignup(page: Page, request: APIRequestContext): Promise { - await prepareAfterSetup(request); - return await registerUser(request, 'alice', 'alice1234'); -} - -async function prepareAfterSignedIn(page: Page, request: APIRequestContext): Promise { - await prepareAfterSignup(page, request); - await visitHome(page); - await signInFromTopPage(page, 'alice', 'alice1234'); -} - -async function prepareAfterUserSetup(page: Page, request: APIRequestContext): Promise { - await prepareAfterSignedIn(page, request); - await dismissAccountSetupWizard(page); -} - -async function runBeforeSetupLoads(page: Page): Promise { - await visitHome(page); -} - -async function runSetupInstance(page: Page): Promise { - await visitHome(page); - - await locateMkInput(page, 'admin-initial-password').fill(ADMIN_SETUP_PASSWORD); - await locateMkInput(page, 'admin-username').fill('admin'); - await locateMkInput(page, 'admin-password').fill('admin1234'); - const signupResponse = waitApiResponse(page, '/api/admin/accounts/create'); - await page.getByTestId('admin-ok').click(); - await signupResponse; - - await page.getByTestId('next').click(); - await locateMkInput(page, 'server-setup-server-name').fill('Testskey'); - const updateMetaResponse = waitApiResponse(page, '/api/admin/update-meta'); - await page.getByTestId('server-setup-wizard-apply').click(); - await updateMetaResponse; -} - -async function runAfterSetupLoads(page: Page, request: APIRequestContext): Promise { - await prepareAfterSetup(request); - await visitHome(page); -} - -async function runSignup(page: Page, request: APIRequestContext): Promise { - await prepareAfterSetup(request); - await visitHome(page); - - await page.getByTestId('signup').click(); - await page.getByTestId('signup-rules-continue').waitFor({ state: 'visible' }); - if (!await page.getByTestId('signup-rules-continue').isDisabled()) { - throw new Error('signup-rules-continue should be disabled before agreement'); - } - await locateMkSwitch(page, 'signup-rules-notes-agree').click(); - await page.getByTestId('modal-dialog-ok').click(); - await page.getByTestId('signup-rules-continue').click(); - - await locateMkInput(page, 'signup-username').fill('alice'); - await locateMkInput(page, 'signup-password').fill('alice1234'); - await locateMkInput(page, 'signup-password-retype').fill('alice1234'); - await locateMkInput(page, 'signup-invitation-code').fill('test-invitation-code'); - const signupResponse = waitApiResponse(page, '/api/signup'); - await page.getByTestId('signup-submit').click(); - await signupResponse; -} - -async function runSignupDuplicated(page: Page, request: APIRequestContext): Promise { - await prepareAfterSetup(request); - await registerUser(request, 'alice', 'alice1234'); - await visitHome(page); - - await page.getByTestId('signup').click(); - await locateMkSwitch(page, 'signup-rules-notes-agree').click(); - await page.getByTestId('modal-dialog-ok').click(); - await page.getByTestId('signup-rules-continue').click(); - await locateMkInput(page, 'signup-username').fill('alice'); - await locateMkInput(page, 'signup-password').fill('alice1234'); - await locateMkInput(page, 'signup-password-retype').fill('alice1234'); - if (!await page.getByTestId('signup-submit').isDisabled()) { - throw new Error('signup-submit should stay disabled for duplicated username'); - } -} - -async function runSignIn(page: Page, request: APIRequestContext): Promise { - await prepareAfterSignup(page, request); - await visitHome(page); - await signInFromTopPage(page, 'alice', 'alice1234'); -} - -async function runSuspend(page: Page, request: APIRequestContext): Promise { - await resetState(request); - const admin = await registerUser(request, 'admin', 'pass', true); - const alice = await registerUser(request, 'alice', 'alice1234'); - const suspendResponse = await request.post(`${BASE_URL}/api/admin/suspend-user`, { - data: { - i: admin.token, - userId: alice.id, - }, - }); - await assertOk(suspendResponse.status(), await suspendResponse.text(), '/api/admin/suspend-user'); - - 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('alice'); - await page.keyboard.press('Enter'); - await page.getByText(/アカウントが凍結されています|This account has been suspended due to/).waitFor({ timeout: 10_000 }); -} - -async function runAfterSignedInLoads(page: Page, request: APIRequestContext): Promise { - await prepareAfterSignedIn(page, request); - await page.getByTestId('user-setup-continue').waitFor({ state: 'visible', timeout: 30_000 }); -} - -async function runAccountSetupWizard(page: Page, request: APIRequestContext): Promise { - await prepareAfterSignedIn(page, request); - await page.getByTestId('user-setup-continue').click({ timeout: 30_000 }); - await locateMkInput(page, 'user-setup-user-name').fill('ありす'); - await locateMkTextarea(page, 'user-setup-user-description').fill('ほげ'); - await page.getByTestId('user-setup-continue').click(); - await page.getByTestId('user-setup-continue').click(); - await page.getByTestId('user-setup-continue').click(); - await page.getByTestId('user-setup-continue').click(); - await page.getByTestId('user-setup-continue').click(); -} - -async function runCreateNote(page: Page, request: APIRequestContext): Promise { - await prepareAfterUserSetup(page, request); - 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: 15_000 }); -} - -async function runOpenNoteFormWithHotkey(page: Page, request: APIRequestContext): Promise { - await prepareAfterUserSetup(page, request); - await page.getByTestId('open-post-form').waitFor({ state: 'visible' }); - await page.keyboard.press('KeyN'); - await page.getByTestId('post-form-text').waitFor({ state: 'visible' }); - await page.keyboard.press('Escape'); - await page.getByTestId('post-form-text').waitFor({ state: 'hidden' }); -} - -async function runRedirectProfile(page: Page, request: APIRequestContext): Promise { - await prepareAfterUserSetup(page, request); - await page.goto(`${BASE_URL}/redirect-test`); - await page.waitForURL('**/@alice'); -} - -const scenarioHandlers = { - 'before-setup-loads': async (page: Page, _request: APIRequestContext) => runBeforeSetupLoads(page), - 'setup-instance': runSetupInstance, - 'after-setup-loads': runAfterSetupLoads, - signup: runSignup, - 'signup-duplicated': runSignupDuplicated, - signin: runSignIn, - suspend: runSuspend, - 'after-signed-in-loads': runAfterSignedInLoads, - 'account-setup-wizard': runAccountSetupWizard, - note: runCreateNote, - 'open-note-form-with-hotkey': runOpenNoteFormWithHotkey, - 'redirect-profile': runRedirectProfile, -} satisfies Record Promise>; - -export const runScenario: BrowserCommand<[scenario: string]> = async (ctx, scenario) => { - if (ctx.provider.name !== 'playwright') { - throw new Error(`playwright provider is required, but got ${ctx.provider.name}`); - } - - if (!(scenario in scenarioHandlers)) { - throw new Error(`unknown e2e scenario: ${scenario}`); - } - const run = scenarioHandlers[scenario as keyof typeof scenarioHandlers]; - - const testPage = await ctx.context.newPage(); - await testPage.addInitScript(() => { - (globalThis as { Cypress?: Record }).Cypress = { browserMode: true }; - }); - - try { - await run(testPage, testPage.request); - } finally { - await testPage.close(); - } -}; diff --git a/packages/frontend/test/e2e/commands.d.ts b/packages/frontend/test/e2e/commands.d.ts deleted file mode 100644 index 2d7d3eb5c3..0000000000 --- a/packages/frontend/test/e2e/commands.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -declare module 'vitest/browser' { - interface BrowserCommands { - runScenario: (scenario: string) => Promise; - } -} - -export {}; diff --git a/packages/frontend/test/e2e/fixtures.ts b/packages/frontend/test/e2e/fixtures.ts new file mode 100644 index 0000000000..09f3a68e39 --- /dev/null +++ b/packages/frontend/test/e2e/fixtures.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { test as base, expect } from '@playwright/test'; +import { IGNORABLE_ERROR_MESSAGES, isIgnorableErrorMessage } from './ignorable-errors.js'; + +/** 通常のtestの代わりにこちらを使用する */ +export const test = base.extend<{ _installIgnorableErrorHandlers: void }>({ + _installIgnorableErrorHandlers: [async ({ page }, use) => { + await page.addInitScript((messages) => { + function includesIgnorableMessage(message: unknown): boolean { + if (typeof message !== 'string') return false; + return messages.some((text) => message.includes(text)); + } + + window.addEventListener('error', (event) => { + if (includesIgnorableMessage(event.message)) { + event.preventDefault(); + } + }); + + window.addEventListener('unhandledrejection', (event) => { + const reason = event.reason; + const message = reason instanceof Error ? reason.message : String(reason); + if (includesIgnorableMessage(message)) { + event.preventDefault(); + } + }); + }, [...IGNORABLE_ERROR_MESSAGES]); + + // Playwright側で収集されるページエラーも同じ基準で握りつぶす。 + page.on('pageerror', (error) => { + if (isIgnorableErrorMessage(error.message)) { + return; + } + }); + + await use(); + }, { auto: true }], +}); + +export { expect }; diff --git a/packages/frontend/test/e2e/ignorable-errors.ts b/packages/frontend/test/e2e/ignorable-errors.ts new file mode 100644 index 0000000000..0677fdeacf --- /dev/null +++ b/packages/frontend/test/e2e/ignorable-errors.ts @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const IGNORABLE_ERROR_MESSAGES = [ + 'The source image cannot be decoded', + 'ResizeObserver loop limit exceeded', + 'ResizeObserver loop completed with undelivered notifications', +] as const; + +export function isIgnorableErrorMessage(message: string): boolean { + return IGNORABLE_ERROR_MESSAGES.some((text) => message.includes(text)); +} diff --git a/packages/frontend/test/e2e/router.browser.test.ts b/packages/frontend/test/e2e/router.browser.test.ts deleted file mode 100644 index 4a401309bc..0000000000 --- a/packages/frontend/test/e2e/router.browser.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { commands } from 'vitest/browser'; -import { describe, it } from 'vitest'; - -describe('Router transition', () => { - describe('Redirect', () => { - it('redirect to user profile', async () => { - await commands.runScenario('redirect-profile'); - }); - }); -}); diff --git a/packages/frontend/test/e2e/router.spec.ts b/packages/frontend/test/e2e/router.spec.ts new file mode 100644 index 0000000000..abc052204e --- /dev/null +++ b/packages/frontend/test/e2e/router.spec.ts @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { test } from './fixtures.js'; +import { + // const + BASE_URL, + // utils + resetState, registerUser, signIn, +} from './utils.js'; + +test.describe('Router transition', () => { + test.beforeAll(async ({ page, request }) => { + await resetState(request); + await registerUser(request, 'admin', 'pass', true); + await registerUser(request, 'alice', 'alice1234'); + 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(); + }); + + test.describe('Redirect', () => { + test('redirect to user profile', async ({ page }) => { + await page.goto(`${BASE_URL}/redirect-test`); + await page.waitForURL('**/@alice'); + }); + }); +}); diff --git a/packages/frontend/test/e2e/utils.ts b/packages/frontend/test/e2e/utils.ts new file mode 100644 index 0000000000..a5e5aaf9e1 --- /dev/null +++ b/packages/frontend/test/e2e/utils.ts @@ -0,0 +1,89 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { Locator, Page, APIRequestContext } from 'playwright'; + +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, body: string, route: string): void { + if (status < 200 || status >= 300) { + throw new Error(`${route} failed: status=${status} body=${body}`); + } +} + +export async function resetState(request: APIRequestContext): Promise { + const response = await request.post(`${BASE_URL}/api/reset-db`, { + data: '{}', + headers: { + 'Content-Type': 'application/json', + }, + }); + assertOk(response.status(), await response.text(), '/api/reset-db'); +} + +export async function registerUser( + request: APIRequestContext, + username: string, + password: string, + isAdmin = false, +): Promise { + const route = isAdmin ? '/api/admin/accounts/create' : '/api/signup'; + const response = await request.post(`${BASE_URL}${route}`, { + data: { + username, + password, + ...(isAdmin ? { setupPassword: ADMIN_SETUP_PASSWORD } : {}), + }, + }); + assertOk(response.status(), await response.text(), route); + return await response.json() as RegisteredUser; +} + +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 }); +} + +export async function signIn(page: Page, username: string, password: string): Promise { + 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; +} +//#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"]`); +} +//#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 }); +} +//#endregion diff --git a/packages/frontend/test/setup.e2e.ts b/packages/frontend/test/setup.e2e.ts deleted file mode 100644 index b58f4a7cd8..0000000000 --- a/packages/frontend/test/setup.e2e.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -function isIgnorableErrorMessage(message: string): boolean { - return [ - 'The source image cannot be decoded', - 'ResizeObserver loop limit exceeded', - 'ResizeObserver loop completed with undelivered notifications', - ].some((text) => message.includes(text)); -} - -window.addEventListener('error', (event) => { - if (isIgnorableErrorMessage(event.message)) { - event.preventDefault(); - } -}); - -window.addEventListener('unhandledrejection', (event) => { - const reason = event.reason; - const message = reason instanceof Error ? reason.message : String(reason); - if (isIgnorableErrorMessage(message)) { - event.preventDefault(); - } -}); diff --git a/packages/frontend/vitest.config.e2e.ts b/packages/frontend/vitest.config.e2e.ts deleted file mode 100644 index e316e4d051..0000000000 --- a/packages/frontend/vitest.config.e2e.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { defineConfig, mergeConfig } from 'vitest/config'; -import { playwright } from '@vitest/browser-playwright'; -import { runScenario } from './test/e2e/browser-commands.js'; -import { getConfig } from './vite.config.js'; - -export default mergeConfig(getConfig(), defineConfig({ - test: { - include: ['./test/e2e/**/*.test.ts'], - exclude: [], - setupFiles: ['./test/setup.e2e.ts'], - fileParallelism: false, - maxWorkers: 1, - testTimeout: 60000, - browser: { - enabled: true, - provider: playwright(), - headless: true, - screenshotFailures: true, - screenshotDirectory: './test/e2e/screenshots', - commands: { - runScenario, - }, - instances: [{ - browser: 'chromium', - }], - }, - }, -})); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39bb8091c7..cd06dcf875 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -692,6 +692,9 @@ importers: '@misskey-dev/summaly': specifier: 5.5.1 version: 5.5.1 + '@playwright/test': + specifier: 1.61.1 + version: 1.61.1 '@rollup/plugin-json': specifier: 6.1.0 version: 6.1.0(rollup@4.62.2) @@ -2957,6 +2960,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -10796,6 +10804,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + '@polka/url@1.0.0-next.29': {} '@readme/better-ajv-errors@2.4.0(ajv@8.20.0)':