1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-25 12:05:19 +02:00
This commit is contained in:
kakkokari-gtyih
2026-07-08 16:00:48 +09:00
parent b50dee9b5b
commit 38888cfe85
18 changed files with 450 additions and 428 deletions

View File

@@ -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

2
.gitignore vendored
View File

@@ -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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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',
},
}],
});

View File

@@ -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');
});
});

View File

@@ -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: 投稿フォームのハッシュタグ保持フィールドのテスト

View File

@@ -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<void> {
if (status >= 200 && status < 300) return;
throw new Error(`${route} failed: status=${status} body=${body}`);
}
async function resetState(request: APIRequestContext): Promise<void> {
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<RegisteredUser> {
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<void> {
await page.waitForResponse((response) => {
return response.url().endsWith(path) && response.request().method() === 'POST';
}, { timeout: 30_000 });
}
async function visitHome(page: Page): Promise<void> {
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<void> {
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<void> {
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<void> {
await resetState(request);
await registerUser(request, 'admin', 'pass', true);
}
async function prepareAfterSignup(page: Page, request: APIRequestContext): Promise<RegisteredUser> {
await prepareAfterSetup(request);
return await registerUser(request, 'alice', 'alice1234');
}
async function prepareAfterSignedIn(page: Page, request: APIRequestContext): Promise<void> {
await prepareAfterSignup(page, request);
await visitHome(page);
await signInFromTopPage(page, 'alice', 'alice1234');
}
async function prepareAfterUserSetup(page: Page, request: APIRequestContext): Promise<void> {
await prepareAfterSignedIn(page, request);
await dismissAccountSetupWizard(page);
}
async function runBeforeSetupLoads(page: Page): Promise<void> {
await visitHome(page);
}
async function runSetupInstance(page: Page): Promise<void> {
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<void> {
await prepareAfterSetup(request);
await visitHome(page);
}
async function runSignup(page: Page, request: APIRequestContext): Promise<void> {
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<void> {
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<void> {
await prepareAfterSignup(page, request);
await visitHome(page);
await signInFromTopPage(page, 'alice', 'alice1234');
}
async function runSuspend(page: Page, request: APIRequestContext): Promise<void> {
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<void> {
await prepareAfterSignedIn(page, request);
await page.getByTestId('user-setup-continue').waitFor({ state: 'visible', timeout: 30_000 });
}
async function runAccountSetupWizard(page: Page, request: APIRequestContext): Promise<void> {
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<void> {
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<void> {
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<void> {
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<string, (page: Page, request: APIRequestContext) => Promise<void>>;
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<string, unknown> }).Cypress = { browserMode: true };
});
try {
await run(testPage, testPage.request);
} finally {
await testPage.close();
}
};

View File

@@ -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<void>;
}
}
export {};

View File

@@ -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 };

View File

@@ -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));
}

View File

@@ -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');
});
});
});

View File

@@ -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');
});
});
});

View File

@@ -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<void> {
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<RegisteredUser> {
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<void> {
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<void> {
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<void> {
await page.goto(`${BASE_URL}/`);
await page.locator('button').first().waitFor({ state: 'visible', timeout: 30_000 });
}
//#endregion

View File

@@ -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();
}
});

View File

@@ -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',
}],
},
},
}));

12
pnpm-lock.yaml generated
View File

@@ -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)':