mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-07-25 12:05:19 +02:00
fix
This commit is contained in:
6
.github/workflows/test-frontend.yml
vendored
6
.github/workflows/test-frontend.yml
vendored
@@ -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
2
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
31
packages/frontend/playwright.config.ts
Normal file
31
packages/frontend/playwright.config.ts
Normal 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',
|
||||
},
|
||||
}],
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
219
packages/frontend/test/e2e/basic.spec.ts
Normal file
219
packages/frontend/test/e2e/basic.spec.ts
Normal 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: 投稿フォームのハッシュタグ保持フィールドのテスト
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
12
packages/frontend/test/e2e/commands.d.ts
vendored
12
packages/frontend/test/e2e/commands.d.ts
vendored
@@ -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 {};
|
||||
44
packages/frontend/test/e2e/fixtures.ts
Normal file
44
packages/frontend/test/e2e/fixtures.ts
Normal 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 };
|
||||
14
packages/frontend/test/e2e/ignorable-errors.ts
Normal file
14
packages/frontend/test/e2e/ignorable-errors.ts
Normal 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));
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
32
packages/frontend/test/e2e/router.spec.ts
Normal file
32
packages/frontend/test/e2e/router.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
89
packages/frontend/test/e2e/utils.ts
Normal file
89
packages/frontend/test/e2e/utils.ts
Normal 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
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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
12
pnpm-lock.yaml
generated
@@ -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)':
|
||||
|
||||
Reference in New Issue
Block a user