1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-26 15:24:39 +02:00

enhance(frontend/test): frontend e2eをVitest Browser Modeに移行

This commit is contained in:
kakkokari-gtyih
2026-07-08 10:10:32 +09:00
parent 03d224acd0
commit 26cb99752d
43 changed files with 612 additions and 1167 deletions

View File

@@ -12,4 +12,4 @@ pnpm install --frozen-lockfile
cp .devcontainer/devcontainer.yml .config/default.yml
pnpm build
pnpm migrate
pnpm exec cypress install
pnpm --filter frontend exec playwright install --with-deps chromium

2
.github/labeler.yml vendored
View File

@@ -16,7 +16,7 @@
'packages/frontend:test':
- any:
- changed-files:
- any-glob-to-any-file: ['cypress/**/*']
- any-glob-to-any-file: ['packages/frontend/test/**/*']
'packages/sw':
- any:

View File

@@ -44,7 +44,6 @@ jobs:
}
directories=(
"cypress/e2e"
"packages/backend/migration"
"packages/backend/src"
"packages/backend/test"

View File

@@ -57,11 +57,6 @@ jobs:
name: E2E tests (frontend)
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
browser: [chrome]
services:
postgres:
image: postgres:18
@@ -79,12 +74,6 @@ jobs:
- uses: actions/checkout@v6.0.3
with:
submodules: true
# https://github.com/cypress-io/cypress-docker-images/issues/150
#- name: Install mplayer for FireFox
# run: sudo apt install mplayer -y
# if: ${{ matrix.browser == 'firefox' }}
#- uses: browser-actions/setup-firefox@latest
# if: ${{ matrix.browser == 'firefox' }}
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
@@ -97,29 +86,13 @@ jobs:
run: cp .github/misskey/test.yml .config
- name: Build
run: pnpm build
# https://github.com/cypress-io/cypress/issues/4351#issuecomment-559489091
- name: ALSA Env
run: echo -e 'pcm.!default {\n type hw\n card 0\n}\n\nctl.!default {\n type hw\n card 0\n}' > ~/.asoundrc
# XXX: This tries reinstalling Cypress if the binary is not cached
# Remove this when the cache issue is fixed
- name: Cypress install
run: pnpm exec cypress install
- name: Cypress run
uses: cypress-io/github-action@v7.4.0
- name: Playwright install
run: pnpm --filter frontend exec playwright install --with-deps chromium
- name: Vitest Browser E2E run
run: pnpm start-server-and-test start:test http://localhost:61812 "pnpm --filter frontend test:e2e"
timeout-minutes: 15
with:
install: false
start: pnpm start:test
wait-on: 'http://localhost:61812'
headed: true
browser: ${{ matrix.browser }}
- uses: actions/upload-artifact@v7
if: failure()
with:
name: ${{ matrix.browser }}-cypress-screenshots
path: cypress/screenshots
- uses: actions/upload-artifact@v7
if: always()
with:
name: ${{ matrix.browser }}-cypress-videos
path: cypress/videos
name: vitest-browser-screenshots
path: packages/frontend/test/e2e/screenshots

5
.gitignore vendored
View File

@@ -23,9 +23,8 @@ packages/sw/.yarn/cache
# pnpm
.pnpm-store
# Cypress
cypress/screenshots
cypress/videos
# Vitest Browser Mode
packages/frontend/test/e2e/screenshots
# Coverage
coverage

View File

@@ -1,7 +0,0 @@
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:61812',
},
})

View File

@@ -1,265 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
describe('Before setup instance', () => {
beforeEach(() => {
cy.resetState();
});
afterEach(() => {
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
// waitを入れることでそれを防止できる
cy.wait(1000);
});
it('successfully loads', () => {
cy.visitHome();
});
it('setup instance', () => {
cy.visitHome();
cy.intercept('POST', '/api/admin/accounts/create').as('signup');
cy.get('[data-cy-admin-initial-password] input').type('example_password_please_change_this_or_you_will_get_hacked');
cy.get('[data-cy-admin-username] input').type('admin');
cy.get('[data-cy-admin-password] input').type('admin1234');
cy.get('[data-cy-admin-ok]').click();
// なぜか動かない
//cy.wait('@signup').should('have.property', 'response.statusCode');
cy.wait('@signup');
cy.intercept('POST', '/api/admin/update-meta').as('update-meta');
cy.get('[data-cy-next]').click();
cy.get('[data-cy-server-name] input').type('Testskey');
cy.get('[data-cy-server-setup-wizard-apply]').click();
cy.wait('@update-meta');
});
});
describe('After setup instance', () => {
beforeEach(() => {
cy.resetState();
// インスタンス初期セットアップ
cy.registerUser('admin', 'pass', true);
});
afterEach(() => {
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
// waitを入れることでそれを防止できる
cy.wait(1000);
});
it('successfully loads', () => {
cy.visitHome();
});
it('signup', () => {
cy.visitHome();
cy.intercept('POST', '/api/signup').as('signup');
cy.get('[data-cy-signup]').click();
cy.get('[data-cy-signup-rules-continue]').should('be.disabled');
cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click();
cy.get('[data-cy-modal-dialog-ok]').click();
cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled');
cy.get('[data-cy-signup-rules-continue]').click();
cy.get('[data-cy-signup-submit]').should('be.disabled');
cy.get('[data-cy-signup-username] input').type('alice');
cy.get('[data-cy-signup-submit]').should('be.disabled');
cy.get('[data-cy-signup-password] input').type('alice1234');
cy.get('[data-cy-signup-submit]').should('be.disabled');
cy.get('[data-cy-signup-password-retype] input').type('alice1234');
cy.get('[data-cy-signup-submit]').should('be.disabled');
cy.get('[data-cy-signup-invitation-code] input').type('test-invitation-code');
cy.get('[data-cy-signup-submit]').should('not.be.disabled');
cy.get('[data-cy-signup-submit]').click();
cy.wait('@signup');
});
it('signup with duplicated username', () => {
cy.registerUser('alice', 'alice1234');
cy.visitHome();
// ユーザー名が重複している場合の挙動確認
cy.get('[data-cy-signup]').click();
cy.get('[data-cy-signup-rules-continue]').should('be.disabled');
cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click();
cy.get('[data-cy-modal-dialog-ok]').click();
cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled');
cy.get('[data-cy-signup-rules-continue]').click();
cy.get('[data-cy-signup-username] input').type('alice');
cy.get('[data-cy-signup-password] input').type('alice1234');
cy.get('[data-cy-signup-password-retype] input').type('alice1234');
cy.get('[data-cy-signup-submit]').should('be.disabled');
});
});
describe('After user signup', () => {
beforeEach(() => {
cy.resetState();
// インスタンス初期セットアップ
cy.registerUser('admin', 'pass', true);
// ユーザー作成
cy.registerUser('alice', 'alice1234');
});
afterEach(() => {
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
// waitを入れることでそれを防止できる
cy.wait(1000);
});
it('successfully loads', () => {
cy.visitHome();
});
it('signin', () => {
cy.visitHome();
cy.intercept('POST', '/api/signin-flow').as('signin');
cy.get('[data-cy-signin]').click();
cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 });
// Enterキーで続行できるかの確認も兼ねる
cy.get('[data-cy-signin-username] input').type('alice{enter}');
cy.get('[data-cy-signin-page-password]').should('be.visible', { timeout: 10000 });
// Enterキーで続行できるかの確認も兼ねる
cy.get('[data-cy-signin-password] input').type('alice1234{enter}');
cy.wait('@signin');
});
it('suspend', function() {
cy.request('POST', '/api/admin/suspend-user', {
i: this.admin.token,
userId: this.alice.id,
});
cy.visitHome();
cy.get('[data-cy-signin]').click();
cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 });
cy.get('[data-cy-signin-username] input').type('alice{enter}');
// TODO: cypressにブラウザの言語指定できる機能が実装され次第英語のみテストするようにする
cy.contains(/アカウントが凍結されています|This account has been suspended due to/gi);
});
});
describe('After user signed in', () => {
beforeEach(() => {
cy.resetState();
// インスタンス初期セットアップ
cy.registerUser('admin', 'pass', true);
// ユーザー作成
cy.registerUser('alice', 'alice1234');
cy.login('alice', 'alice1234');
});
afterEach(() => {
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
// waitを入れることでそれを防止できる
cy.wait(1000);
});
it('successfully loads', () => {
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする
cy.get('[data-cy-user-setup-continue]', { timeout: 30000 }).should('be.visible');
});
it('account setup wizard', () => {
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする
cy.get('[data-cy-user-setup-continue]', { timeout: 30000 }).click();
cy.get('[data-cy-user-setup-user-name] input').type('ありす');
cy.get('[data-cy-user-setup-user-description] textarea').type('ほげ');
// TODO: アイコン設定テスト
cy.get('[data-cy-user-setup-continue]').click();
// プライバシー設定
cy.get('[data-cy-user-setup-continue]').click();
// フォローはスキップ
cy.get('[data-cy-user-setup-continue]').click();
// プッシュ通知設定はスキップ
cy.get('[data-cy-user-setup-continue]').click();
cy.get('[data-cy-user-setup-continue]').click();
});
});
describe('After user setup', () => {
beforeEach(() => {
cy.resetState();
// インスタンス初期セットアップ
cy.registerUser('admin', 'pass', true);
// ユーザー作成
cy.registerUser('alice', 'alice1234');
cy.login('alice', 'alice1234');
// アカウント初期設定ウィザード
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする
cy.get('[data-cy-user-setup] [data-cy-modal-window-close]', { timeout: 30000 }).click();
cy.get('[data-cy-modal-dialog-ok]').click();
});
afterEach(() => {
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
// waitを入れることでそれを防止できる
cy.wait(1000);
});
it('note', () => {
cy.get('[data-cy-open-post-form]').should('be.visible');
cy.get('[data-cy-open-post-form]').click();
cy.get('[data-cy-post-form-text]').type('Hello, Misskey!');
cy.get('[data-cy-open-post-form-submit]').click();
cy.contains('Hello, Misskey!', { timeout: 15000 });
});
it('open note form with hotkey', () => {
// Wait until the page loads
cy.get('[data-cy-open-post-form]').should('be.visible');
// Use trigger() to give different `code` to test if hotkeys also work on non-QWERTY keyboards.
cy.document().trigger("keydown", { eventConstructor: 'KeyboardEvent', key: "n", code: "KeyL" });
// See if the form is opened
cy.get('[data-cy-post-form-text]').should('be.visible');
// Close it
cy.focused().trigger("keydown", { eventConstructor: 'KeyboardEvent', key: "Escape", code: "Escape" });
// See if the form is closed
cy.get('[data-cy-post-form-text]').should('not.be.visible');
});
});
// TODO: 投稿フォームの公開範囲指定のテスト
// TODO: 投稿フォームのファイル添付のテスト
// TODO: 投稿フォームのハッシュタグ保持フィールドのテスト

View File

@@ -1,35 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
describe('Router transition', () => {
describe('Redirect', () => {
// サーバの初期化。ルートのテストに関しては各describeごとに1度だけ実行で十分だと思う使いまわした方が早い
before(() => {
cy.resetState();
// インスタンス初期セットアップ
cy.registerUser('admin', 'pass', true);
// ユーザー作成
cy.registerUser('alice', 'alice1234');
cy.login('alice', 'alice1234');
// アカウント初期設定ウィザード
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする
cy.get('[data-cy-user-setup] [data-cy-modal-window-close]', { timeout: 30000 }).click();
cy.wait(500);
cy.get('[data-cy-modal-dialog-ok]').click();
});
it('redirect to user profile', () => {
// テストのためだけに用意されたリダイレクト用ルートに飛ぶ
cy.visit('/redirect-test');
// プロフィールページのURLであることを確認する
cy.url().should('include', '/@alice')
});
});
});

View File

@@ -1,76 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* flaky
describe('After user signed in', () => {
beforeEach(() => {
cy.resetState();
cy.viewport('macbook-16');
// インスタンス初期セットアップ
cy.registerUser('admin', 'pass', true);
// ユーザー作成
cy.registerUser('alice', 'alice1234');
cy.login('alice', 'alice1234');
// アカウント初期設定ウィザード
cy.get('[data-cy-user-setup] [data-cy-modal-window-close]').click();
cy.get('[data-cy-modal-dialog-ok]').click();
});
afterEach(() => {
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
// waitを入れることでそれを防止できる
cy.wait(1000);
});
it('widget edit toggle is visible', () => {
cy.get('[data-cy-widget-edit]').should('be.visible');
});
it('widget select should be visible in edit mode', () => {
cy.get('[data-cy-widget-edit]').click();
cy.get('[data-cy-widget-select]').should('be.visible');
});
it('first widget should be removed', () => {
cy.get('[data-cy-widget-edit]').click();
cy.get('[data-cy-customize-container]:first-child [data-cy-customize-container-remove]._button').click();
cy.get('[data-cy-customize-container]').should('have.length', 2);
});
function buildWidgetTest(widgetName) {
it(`${widgetName} widget should get added`, () => {
cy.get('[data-cy-widget-edit]').click();
cy.get('[data-cy-widget-select] select').select(widgetName, { force: true });
cy.get('[data-cy-bg]._modalBg[data-cy-transparent]').click({ multiple: true, force: true });
cy.get('[data-cy-widget-add]').click({ force: true });
cy.get(`[data-cy-mkw-${widgetName}]`).should('exist');
});
}
buildWidgetTest('memo');
buildWidgetTest('notifications');
buildWidgetTest('timeline');
buildWidgetTest('calendar');
buildWidgetTest('rss');
buildWidgetTest('trends');
buildWidgetTest('clock');
buildWidgetTest('activity');
buildWidgetTest('photos');
buildWidgetTest('digitalClock');
buildWidgetTest('federation');
buildWidgetTest('postForm');
buildWidgetTest('slideshow');
buildWidgetTest('serverMetric');
buildWidgetTest('onlineUsers');
buildWidgetTest('jobQueue');
buildWidgetTest('button');
buildWidgetTest('aiscript');
buildWidgetTest('aichan');
});
*/

View File

@@ -1,5 +0,0 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

View File

@@ -1,67 +0,0 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
Cypress.Commands.add('visitHome', () => {
cy.visit('/');
cy.get('button', { timeout: 30000 }).should('be.visible');
})
Cypress.Commands.add('resetState', () => {
// iframe.contentWindow.indexedDB.deleteDatabase() がchromeのバグで使用できないため、indexedDBを無効化している。
// see https://github.com/misskey-dev/misskey/issues/13605#issuecomment-2053652123
/*
cy.window().then(win => {
win.indexedDB.deleteDatabase('keyval-store');
});
*/
cy.request('POST', '/api/reset-db', {}).as('reset');
cy.get('@reset').its('status').should('equal', 204);
cy.reload(true);
});
Cypress.Commands.add('registerUser', (username, password, isAdmin = false) => {
const route = isAdmin ? '/api/admin/accounts/create' : '/api/signup';
cy.request('POST', route, {
username: username,
password: password,
...(isAdmin ? { setupPassword: 'example_password_please_change_this_or_you_will_get_hacked' } : {}),
}).its('body').as(username);
});
Cypress.Commands.add('login', (username, password) => {
cy.visitHome();
cy.intercept('POST', '/api/signin-flow').as('signin');
cy.get('[data-cy-signin]').click();
cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 });
cy.get('[data-cy-signin-username] input').type(`${username}{enter}`);
cy.get('[data-cy-signin-page-password]').should('be.visible', { timeout: 10000 });
cy.get('[data-cy-signin-password] input').type(`${password}{enter}`);
cy.wait('@signin').as('signedIn');
});

View File

@@ -1,34 +0,0 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')
Cypress.on('uncaught:exception', (err, runnable) => {
if ([
'The source image cannot be decoded',
// Chrome
'ResizeObserver loop limit exceeded',
// Firefox
'ResizeObserver loop completed with undelivered notifications',
].some(msg => err.message.includes(msg))) {
return false;
}
});

View File

@@ -1,19 +0,0 @@
declare global {
namespace Cypress {
interface Chainable {
login(username: string, password: string): Chainable<void>;
registerUser(
username: string,
password: string,
isAdmin?: boolean
): Chainable<void>;
resetState(): Chainable<void>;
visitHome(): Chainable<void>;
}
}
}
export {}

View File

@@ -1,8 +0,0 @@
{
"compilerOptions": {
"lib": ["dom"],
"target": "esnext",
"types": ["cypress", "node"]
},
"include": ["./**/*.ts"]
}

View File

@@ -41,10 +41,8 @@
"dev": "node scripts/dev.mjs",
"check-dts": "node scripts/check-dts.mjs",
"lint": "pnpm --no-bail -r lint && pnpm check-dts",
"cy:open": "pnpm cypress open --config-file=cypress.config.ts",
"cy:run": "pnpm cypress run",
"e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run",
"e2e-dev-container": "ncp ./.config/cypress-devcontainer.yml ./.config/test.yml && pnpm start-server-and-test start:test http://localhost:61812 cy:run",
"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\"",
"backend-unit-test": "cd packages/backend && pnpm test",
"backend-unit-test-and-coverage": "cd packages/backend && pnpm test-and-coverage",
"test": "pnpm -r test",
@@ -68,7 +66,6 @@
"@typescript-eslint/parser": "8.62.0",
"@typescript/native-preview": "7.0.0-dev.20260426.1",
"cross-env": "10.1.0",
"cypress": "15.18.0",
"eslint": "9.39.4",
"globals": "17.7.0",
"ncp": "2.0.0",

View File

@@ -9,8 +9,9 @@
"build-storybook-pre": "(tsgo -p .storybook || echo done.) && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js",
"build-storybook": "pnpm build-storybook-pre && storybook build --webpack-stats-json storybook-static",
"chromatic": "chromatic",
"test": "vitest --run --globals",
"test-and-coverage": "vitest --run --coverage --globals",
"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",
"typecheck": "vue-tsc --noEmit",
"eslint": "eslint --quiet \"src/**/*.{ts,vue}\"",
"lint": "pnpm typecheck && pnpm eslint"
@@ -102,6 +103,8 @@
"@types/textarea-caret": "3.0.4",
"@types/throttle-debounce": "5.0.2",
"@types/tinycolor2": "1.4.6",
"@vitest/browser": "4.1.9",
"@vitest/browser-playwright": "4.1.9",
"@typescript-eslint/eslint-plugin": "8.62.0",
"@typescript-eslint/parser": "8.62.0",
"@vitest/coverage-v8": "4.1.9",
@@ -120,6 +123,7 @@
"msw-storybook-addon": "2.0.7",
"nodemon": "3.1.14",
"oxc-walker": "1.0.0",
"playwright": "1.56.0",
"prettier": "3.9.1",
"react": "19.2.7",
"react-dom": "19.2.7",

View File

@@ -0,0 +1,61 @@
/*
* 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,268 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { APIRequestContext, Page } 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`);
await assertOk(response.status(), await response.text(), '/api/reset-db');
}
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 page.locator('[data-cy-signin-username] input').fill(username);
await page.keyboard.press('Enter');
await page.getByTestId('signin-page-password').waitFor({ state: 'visible', timeout: 10_000 });
await page.locator('[data-cy-signin-password] input').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-cy-user-setup] [data-cy-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 page.locator('[data-cy-admin-initial-password] input').fill(ADMIN_SETUP_PASSWORD);
await page.locator('[data-cy-admin-username] input').fill('admin');
await page.locator('[data-cy-admin-password] input').fill('admin1234');
const signupResponse = waitApiResponse(page, '/api/admin/accounts/create');
await page.getByTestId('admin-ok').click();
await signupResponse;
await page.getByTestId('next').click();
await page.locator('[data-cy-server-name] input').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 page.locator('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click();
await page.getByTestId('modal-dialog-ok').click();
await page.getByTestId('signup-rules-continue').click();
await page.locator('[data-cy-signup-username] input').fill('alice');
await page.locator('[data-cy-signup-password] input').fill('alice1234');
await page.locator('[data-cy-signup-password-retype] input').fill('alice1234');
await page.locator('[data-cy-signup-invitation-code] input').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 page.locator('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click();
await page.getByTestId('modal-dialog-ok').click();
await page.getByTestId('signup-rules-continue').click();
await page.locator('[data-cy-signup-username] input').fill('alice');
await page.locator('[data-cy-signup-password] input').fill('alice1234');
await page.locator('[data-cy-signup-password-retype] input').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 page.locator('[data-cy-signin-username] input').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 page.locator('[data-cy-user-setup-user-name] input').fill('ありす');
await page.locator('[data-cy-user-setup-user-description] textarea').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('open-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.evaluate(() => {
document.dispatchEvent(new KeyboardEvent('keydown', {
key: 'n',
code: 'KeyL',
bubbles: true,
}));
});
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

@@ -0,0 +1,12 @@
/*
* 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,15 @@
/*
* 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,26 @@
/*
* 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,4 +1,3 @@
/// <reference types="vitest/config" />
import path from 'path';
import pluginVue from '@vitejs/plugin-vue';
import pluginGlsl from 'vite-plugin-glsl';
@@ -261,22 +260,6 @@ export function getConfig(): UserConfig {
worker: {
format: 'es',
},
test: {
environment: 'happy-dom',
setupFiles: ['./test/init.ts'],
deps: {
optimizer: {
web: {
include: [
// XXX: misskey-dev/browser-image-resizer has no "type": "module"
'browser-image-resizer',
],
},
},
},
includeSource: ['src/**/*.ts'],
},
};
}

View File

@@ -0,0 +1,28 @@
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',
}],
},
},
}));

View File

@@ -0,0 +1,22 @@
import { defineConfig, mergeConfig } from 'vitest/config';
import { getConfig } from './vite.config.js';
export default mergeConfig(getConfig(), defineConfig({
test: {
include: ['./test/unit/**/*.test.ts'],
environment: 'happy-dom',
setupFiles: ['./test/init.ts'],
deps: {
optimizer: {
web: {
include: [
// XXX: misskey-dev/browser-image-resizer has no "type": "module"
'browser-image-resizer',
],
},
},
},
includeSource: ['src/**/*.ts'],
},
}));

750
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff