diff --git a/.env.test b/.env.test index ab77dc1307..014c32203d 100644 --- a/.env.test +++ b/.env.test @@ -1,12 +1,14 @@ +# テスト用の.envファイル。モックを使う。 + VITE_APP_NAME=voicevox VITE_DEFAULT_ENGINE_INFOS=`[ { - "uuid": "074fc39e-678b-4c13-8916-ffca8d505d1d", - "name": "VOICEVOX Engine", - "executionEnabled": true, - "executionFilePath": "../voicevox_engine/run.exe", + "name": "Mock Engine", + "uuid": "00000000-0000-0000-0000-000000000000", + "executionEnabled": false, + "executionFilePath": "dummy/path", "executionArgs": [], - "host": "http://127.0.0.1:50021" + "host": "mock://mock" } ]` VITE_OFFICIAL_WEBSITE_URL=https://voicevox.hiroshiba.jp/ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1b3fb1cd02..b06a4b0dd0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -100,16 +100,6 @@ jobs: dest: ${{ github.workspace }}/voicevox_engine target: ${{ matrix.voicevox_engine_asset_name }} - - name: Setup - run: | - # .env - cp tests/env/.env.test-e2e .env - sed -i -e 's|"path/to/engine"|"${{ steps.download-engine.outputs.run_path }}"|' .env - # GitHub Actions 環境だとたまに50021が封じられていることがあるので、ランダムなポートを使うようにする - PORT=$(node -r net -e "server=net.createServer();server.listen(0,()=>{console.log(server.address().port);server.close()})") - sed -i -e 's|random_port|'$PORT'|' .env - cat .env # ログ用 - - name: Run npm run test:browser-e2e run: | if [ -n "${{ runner.debug }}" ]; then @@ -123,6 +113,14 @@ jobs: - name: Run npm run test:electron-e2e run: | + # .env + cp tests/env/.env.test-electron .env + sed -i -e 's|"path/to/engine"|"${{ steps.download-engine.outputs.run_path }}"|' .env + # GitHub Actions 環境だとたまに50021が封じられていることがあるので、ランダムなポートを使うようにする + PORT=$(node -r net -e "server=net.createServer();server.listen(0,()=>{console.log(server.address().port);server.close()})") + sed -i -e 's|random_port|'$PORT'|' .env + cat .env # ログ用 + if [ -n "${{ runner.debug }}" ]; then export DEBUG="pw:browser*" fi @@ -132,6 +130,8 @@ jobs: npm run test:electron-e2e fi + rm .env + - name: Run npm run test:storybook-vrt run: | if [ -n "${{ runner.debug }}" ]; then diff --git a/playwright.config.ts b/playwright.config.ts index 155d4e8b75..529bf25210 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,8 +1,15 @@ -import type { PlaywrightTestConfig, Project } from "@playwright/test"; -import { z } from "zod"; +/** + * e2eテストと .env の設計: + * - デフォルトで .env.test を読み込む。 + * モックエンジンが使われる。 + * - Electronテストはテストファイル内で様々な .env を読み込む。 + * テスト条件によって用意したい環境が異なるため。 + */ +import type { PlaywrightTestConfig, Project } from "@playwright/test"; import dotenv from "dotenv"; -dotenv.config({ override: true }); + +dotenv.config({ path: ".env.test", override: true }); let project: Project; let webServers: PlaywrightTestConfig["webServer"]; @@ -10,26 +17,6 @@ const isElectron = process.env.VITE_TARGET === "electron"; const isBrowser = process.env.VITE_TARGET === "browser"; const isStorybook = process.env.TARGET === "storybook"; -// エンジンの起動が必要 -const defaultEngineInfosEnv = process.env.VITE_DEFAULT_ENGINE_INFOS ?? "[]"; -const envSchema = z // FIXME: electron起動時のものと共通化したい - .object({ - host: z.string(), - executionFilePath: z.string(), - executionArgs: z.array(z.string()), - executionEnabled: z.boolean(), - }) - .passthrough() - .array(); -const engineInfos = envSchema.parse(JSON.parse(defaultEngineInfosEnv)); - -const engineServers = engineInfos - .filter((info) => info.executionEnabled) - .map((info) => ({ - command: `${info.executionFilePath} ${info.executionArgs.join(" ")}`, - url: `${info.host}/version`, - reuseExistingServer: !process.env.CI, - })); const viteServer = { command: "vite --mode test --port 7357", port: 7357, @@ -46,7 +33,7 @@ if (isElectron) { webServers = [viteServer]; } else if (isBrowser) { project = { name: "browser", testDir: "./tests/e2e/browser" }; - webServers = [viteServer, ...engineServers]; + webServers = [viteServer]; } else if (isStorybook) { project = { name: "storybook", testDir: "./tests/e2e/storybook" }; webServers = [storybookServer]; diff --git a/src/infrastructures/EngineConnector.ts b/src/infrastructures/EngineConnector.ts index 7ddede8273..be2870b803 100644 --- a/src/infrastructures/EngineConnector.ts +++ b/src/infrastructures/EngineConnector.ts @@ -1,3 +1,5 @@ +import { createEngineUrl, EngineUrlParams } from "@/domain/url"; +import { createOpenAPIEngineMock } from "@/mock/engineMock"; import { Configuration, DefaultApi, DefaultApiInterface } from "@/openapi"; export interface IEngineConnectorFactory { @@ -6,6 +8,7 @@ export interface IEngineConnectorFactory { instance: (host: string) => DefaultApiInterface; } +// 通常エンジン const OpenAPIEngineConnectorFactoryImpl = (): IEngineConnectorFactory => { const instanceMapper: Record = {}; return { @@ -21,6 +24,45 @@ const OpenAPIEngineConnectorFactoryImpl = (): IEngineConnectorFactory => { }, }; }; - export const OpenAPIEngineConnectorFactory = OpenAPIEngineConnectorFactoryImpl(); + +// モック用エンジン +const OpenAPIMockEngineConnectorFactoryImpl = (): IEngineConnectorFactory => { + let mockInstance: DefaultApiInterface | undefined; + return { + instance: () => { + if (!mockInstance) { + mockInstance = createOpenAPIEngineMock(); + } + return mockInstance; + }, + }; +}; +export const OpenAPIMockEngineConnectorFactory = + OpenAPIMockEngineConnectorFactoryImpl(); + +// 通常エンジンとモック用エンジンの両対応 +// モック用エンジンのURLのときはモックを、そうじゃないときは通常エンジンを返す。 +const OpenAPIEngineAndMockConnectorFactoryImpl = + (): IEngineConnectorFactory => { + // モック用エンジンのURLは `mock://mock` とする + const mockUrlParams: EngineUrlParams = { + protocol: "mock:", + hostname: "mock", + port: "", + pathname: "", + }; + + return { + instance: (host: string) => { + if (host == createEngineUrl(mockUrlParams)) { + return OpenAPIMockEngineConnectorFactory.instance(host); + } else { + return OpenAPIEngineConnectorFactory.instance(host); + } + }, + }; + }; +export const OpenAPIEngineAndMockConnectorFactory = + OpenAPIEngineAndMockConnectorFactoryImpl(); diff --git a/src/store/proxy.ts b/src/store/proxy.ts index 283e2e9fd2..0873c974a1 100644 --- a/src/store/proxy.ts +++ b/src/store/proxy.ts @@ -1,8 +1,10 @@ import { ProxyStoreState, ProxyStoreTypes, EditorAudioQuery } from "./type"; import { createPartialStore } from "./vuex"; import { createEngineUrl } from "@/domain/url"; +import { isElectron, isProduction } from "@/helpers/platform"; import { IEngineConnectorFactory, + OpenAPIEngineAndMockConnectorFactory, OpenAPIEngineConnectorFactory, } from "@/infrastructures/EngineConnector"; import { AudioQuery } from "@/openapi"; @@ -69,4 +71,11 @@ export const convertAudioQueryFromEngineToEditor = ( }; }; -export const proxyStore = proxyStoreCreator(OpenAPIEngineConnectorFactory); +// 製品PC版は通常エンジンのみを、それ以外はモックエンジンも使えるようする +const getConnectorFactory = () => { + if (isElectron && isProduction) { + return OpenAPIEngineConnectorFactory; + } + return OpenAPIEngineAndMockConnectorFactory; +}; +export const proxyStore = proxyStoreCreator(getConnectorFactory()); diff --git "a/tests/e2e/browser/\343\202\242\343\202\257\343\202\273\343\203\263\343\203\210.spec.ts" "b/tests/e2e/browser/\343\202\242\343\202\257\343\202\273\343\203\263\343\203\210.spec.ts" index b18d149670..02a611fee7 100644 --- "a/tests/e2e/browser/\343\202\242\343\202\257\343\202\273\343\203\263\343\203\210.spec.ts" +++ "b/tests/e2e/browser/\343\202\242\343\202\257\343\202\273\343\203\263\343\203\210.spec.ts" @@ -30,7 +30,6 @@ test("アクセントの読み部分をクリックすると読みを変更で await expect(page.locator(".text-cell").first()).toBeVisible(); await page.locator(".text-cell").first().click(); const input = page.getByLabel("1番目のアクセント区間の読み"); - await input.evaluate((node) => console.log(node.outerHTML)); expect(await input.inputValue()).toBe("テストデス"); await input.fill("テストテスト"); await input.press("Enter"); diff --git "a/tests/e2e/browser/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts" "b/tests/e2e/browser/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts" index 485eb7fb66..37196f334a 100644 --- "a/tests/e2e/browser/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts" +++ "b/tests/e2e/browser/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts" @@ -1,130 +1,6 @@ -import path from "path"; -import fs from "fs/promises"; import { test, expect } from "@playwright/test"; import { gotoHome, navigateToMain } from "../navigators"; -import { - Speaker, - SpeakerFromJSON, - SpeakerInfo, - SpeakerInfoFromJSON, - SpeakerInfoToJSON, - SpeakerToJSON, -} from "@/openapi"; -let speakerImages: - | { - portrait: string; - icon: string; - }[] - | undefined = undefined; - -/** - * 差し替え用の立ち絵・アイコンを取得する。 - * TODO: エンジンモックを使ってこのコードを削除する。 - */ -async function getSpeakerImages(): Promise< - { - portrait: string; - icon: string; - }[] -> { - if (!speakerImages) { - const assetsPath = path.resolve( - __dirname, - "../../../src/mock/engineMock/assets", - ); - const images = await fs.readdir(assetsPath); - const icons = images.filter((image) => image.startsWith("icon")); - icons.sort( - (a, b) => - parseInt(a.split(".")[0].split("_")[1]) - - parseInt(b.split(".")[0].split("_")[1]), - ); - speakerImages = await Promise.all( - icons.map(async (iconPath) => { - const portraitPath = iconPath.replace("icon_", "portrait_"); - const portrait = await fs.readFile( - path.join(assetsPath, portraitPath), - "base64", - ); - const icon = await fs.readFile( - path.join(assetsPath, iconPath), - "base64", - ); - - return { portrait, icon }; - }), - ); - } - return speakerImages; -} - -test.beforeEach(async ({ page }) => { - let speakers: Speaker[]; - const speakerImages = await getSpeakerImages(); - // Voicevox Nemo EngineでもVoicevox Engineでも同じ結果が選られるように、 - // GET /speakers、GET /speaker_infoの話者名、スタイル名、画像を差し替える。 - await page.route(/\/speakers$/, async (route) => { - const response = await route.fetch(); - const json: Speaker[] = await response - .json() - .then((json: unknown[]) => json.map(SpeakerFromJSON)); - let i = 0; - for (const speaker of json) { - i++; - speaker.name = `Speaker ${i}`; - let j = 0; - for (const style of speaker.styles) { - j++; - style.name = `Style ${i}-${j}`; - } - } - speakers = json; - await route.fulfill({ - status: 200, - headers: { - "Access-Control-Allow-Origin": "*", - "Content-Type": "application/json", - }, - body: JSON.stringify(json.map(SpeakerToJSON)), - }); - }); - await page.route(/\/speaker_info\?/, async (route) => { - if (!speakers) { - // Unreachableのはず - throw new Error("speakers is not initialized"); - } - const url = new URL(route.request().url()); - const speakerUuid = url.searchParams.get("speaker_uuid"); - if (!speakerUuid) { - throw new Error("speaker_uuid is not set"); - } - const response = await route.fetch(); - const json: SpeakerInfo = await response.json().then(SpeakerInfoFromJSON); - const speakerIndex = speakers.findIndex( - (speaker) => speaker.speakerUuid === speakerUuid, - ); - if (speakerIndex === -1) { - throw new Error(`speaker_uuid=${speakerUuid} is not found`); - } - const image = speakerImages[speakerIndex % speakerImages.length]; - json.portrait = image.portrait; - for (const style of json.styleInfos) { - style.icon = image.icon; - if ("portrait" in style) { - delete style.portrait; - } - } - await route.fulfill({ - status: 200, - headers: { - "Access-Control-Allow-Origin": "*", - "Content-Type": "application/json", - }, - body: JSON.stringify(SpeakerInfoToJSON(json)), - }); - }); -}); test.beforeEach(gotoHome); test("メイン画面の表示", async ({ page }) => { diff --git "a/tests/e2e/browser/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts-snapshots/\343\202\275\343\203\263\343\202\260\347\224\273\351\235\242-browser-win32.png" "b/tests/e2e/browser/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts-snapshots/\343\202\275\343\203\263\343\202\260\347\224\273\351\235\242-browser-win32.png" index 32da40c0dd..c5e5a5971d 100644 Binary files "a/tests/e2e/browser/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts-snapshots/\343\202\275\343\203\263\343\202\260\347\224\273\351\235\242-browser-win32.png" and "b/tests/e2e/browser/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts-snapshots/\343\202\275\343\203\263\343\202\260\347\224\273\351\235\242-browser-win32.png" differ diff --git "a/tests/e2e/browser/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts-snapshots/\343\203\210\343\203\274\343\202\257\347\224\273\351\235\242-browser-win32.png" "b/tests/e2e/browser/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts-snapshots/\343\203\210\343\203\274\343\202\257\347\224\273\351\235\242-browser-win32.png" index 9ba500101c..54332e013d 100644 Binary files "a/tests/e2e/browser/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts-snapshots/\343\203\210\343\203\274\343\202\257\347\224\273\351\235\242-browser-win32.png" and "b/tests/e2e/browser/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts-snapshots/\343\203\210\343\203\274\343\202\257\347\224\273\351\235\242-browser-win32.png" differ diff --git "a/tests/e2e/browser/\350\276\236\346\233\270\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260.spec.ts" "b/tests/e2e/browser/\350\276\236\346\233\270\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260.spec.ts" index 9d8df8039b..bfd2e9b0db 100644 --- "a/tests/e2e/browser/\350\276\236\346\233\270\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260.spec.ts" +++ "b/tests/e2e/browser/\350\276\236\346\233\270\343\203\200\343\202\244\343\202\242\343\203\255\343\202\260.spec.ts" @@ -50,17 +50,12 @@ async function validateInputTag( test("「設定」→「読み方&アクセント辞書」で「読み方&アクセント辞書」ページが表示される", async ({ page, }) => { - test.skip(!process.env.CI, "環境変数CIが未設定のためスキップします"); await navigateToMain(page); - // テスト用にランダムな文字列を生成 - const randomString = Math.random().toString(36).slice(-8); - const zenkakuRandomString = randomString.replace(/[\u0021-\u007e]/g, (s) => { - return String.fromCharCode(s.charCodeAt(0) + 0xfee0); - }); + const targetString = "あいうえお"; // 文字列を入力して読み方を記憶する - const yomi = await getYomi(page, randomString); + const yomi = await getYomi(page, targetString); // 読み方の設定画面を開く await openDictDialog(page); @@ -74,9 +69,9 @@ test("「設定」→「読み方&アクセント辞書」で「読み方& await wordInputTag.evaluate((e: HTMLInputElement, rs: string) => { e.value = rs; e.dispatchEvent(new Event("input")); - }, randomString); + }, targetString); await page.waitForTimeout(100); - await validateInputTag(page, wordInputTag, zenkakuRandomString); + await validateInputTag(page, wordInputTag, targetString); const yomiInputTag = page .locator(".word-editor .row") @@ -101,21 +96,14 @@ test("「設定」→「読み方&アクセント辞書」で「読み方& // 辞書が登録されているかどうかを確認 await page.getByRole("button").filter({ hasText: "add" }).click(); await page.waitForTimeout(100); - const yomi2 = await getYomi(page, randomString); + const yomi2 = await getYomi(page, targetString); expect(yomi2).toBe("テスト"); - // もう一度設定を開き辞書からabsを削除 + // もう一度設定を開き辞書から削除 await openDictDialog(page); - await page - .getByRole("listitem") - .filter({ hasText: zenkakuRandomString }) - .click(); - await page.waitForTimeout(100); - await page - .getByRole("listitem") - .filter({ hasText: zenkakuRandomString }) - .getByText("delete") - .click(); + const wordItem = page.getByRole("listitem").filter({ hasText: targetString }); + await wordItem.hover(); + await wordItem.getByText("delete").click(); await page.waitForTimeout(100); await getNewestQuasarDialog(page) .getByRole("button") @@ -134,6 +122,6 @@ test("「設定」→「読み方&アクセント辞書」で「読み方& // (=最初の読み方と同じになっていることを確認) await page.getByRole("button").filter({ hasText: "add" }).click(); await page.waitForTimeout(100); - const yomi3 = await getYomi(page, randomString); + const yomi3 = await getYomi(page, targetString); expect(yomi3).toBe(yomi); }); diff --git "a/tests/e2e/browser/\351\237\263\345\243\260\350\251\263\347\264\260.spec.ts" "b/tests/e2e/browser/\351\237\263\345\243\260\350\251\263\347\264\260.spec.ts" index dc8533309e..57b873104b 100644 --- "a/tests/e2e/browser/\351\237\263\345\243\260\350\251\263\347\264\260.spec.ts" +++ "b/tests/e2e/browser/\351\237\263\345\243\260\350\251\263\347\264\260.spec.ts" @@ -14,7 +14,7 @@ test("単体アクセント句の読み変更", async ({ page }) => { const textField = page.getByRole("textbox", { name: "1行目" }); await textField.click(); - await textField.fill("1234"); + await textField.fill("あれもこれもそれもどれも"); await textField.press("Enter"); const inputs = Array.from({ length: 4 }, (_, i) => @@ -22,32 +22,32 @@ test("単体アクセント句の読み変更", async ({ page }) => { ); // 読点を追加 - await page.getByText("セ", { exact: true }).click(); - await inputs[0].fill("セン、"); + await page.getByText("ア", { exact: true }).click(); + await inputs[0].fill("アレモ、"); await inputs[0].press("Enter"); await page.waitForTimeout(100); - await expect(page.getByText("セン、")).toBeVisible(); + await expect(page.getByText("アレモ、")).toBeVisible(); // 「,」が読点に変換される - await page.getByText("ヒャ", { exact: true }).click(); - await inputs[1].fill("ニヒャク,"); + await page.getByText("コ", { exact: true }).click(); + await inputs[1].fill("コレモ,"); await inputs[1].press("Enter"); await page.waitForTimeout(100); - await expect(page.getByText("ニヒャク、")).toBeVisible(); + await expect(page.getByText("コレモ、")).toBeVisible(); // 連続する読点を追加すると1つに集約される - await page.getByText("ジュ", { exact: true }).click(); - await inputs[2].fill("サンジュウ,、,、"); + await page.getByText("ソ", { exact: true }).click(); + await inputs[2].fill("ソレモ,、,、"); await inputs[2].press("Enter"); await page.waitForTimeout(100); - await expect(page.getByText("サンジュウ、")).toBeVisible(); + await expect(page.getByText("ソレモ、")).toBeVisible(); // 最後のアクセント区間に読点をつけても無視される - await page.getByText("ヨ", { exact: true }).click(); - await inputs[3].fill("ヨン,、,、"); + await page.getByText("ド", { exact: true }).click(); + await inputs[3].fill("ドレモ,、,、"); await inputs[3].press("Enter"); await page.waitForTimeout(100); - await expect(page.getByText("ヨン、")).not.toBeVisible(); + await expect(page.getByText("ドレモ、")).not.toBeVisible(); }); test("詳細調整欄のコンテキストメニュー", async ({ page }) => { @@ -56,9 +56,11 @@ test("詳細調整欄のコンテキストメニュー", async ({ page }) => { // 削除 await page.getByRole("textbox", { name: "1行目" }).click(); - await page.getByRole("textbox", { name: "1行目" }).fill("1234"); + await page + .getByRole("textbox", { name: "1行目" }) + .fill("あれもこれもそれもどれも"); await page.getByRole("textbox", { name: "1行目" }).press("Enter"); - await page.getByText("サンジュウ").click({ + await page.getByText("ソレモ").click({ button: "right", }); await page @@ -66,7 +68,7 @@ test("詳細調整欄のコンテキストメニュー", async ({ page }) => { .filter({ has: page.getByText("削除") }) .click(); await page.waitForTimeout(100); - await expect(page.getByText("サンジュウ")).not.toBeVisible(); - await expect(page.getByText("ニヒャク")).toBeVisible(); - await expect(page.getByText("ヨン")).toBeVisible(); + await expect(page.getByText("ソレモ")).not.toBeVisible(); + await expect(page.getByText("コレモ")).toBeVisible(); + await expect(page.getByText("ドレモ")).toBeVisible(); }); diff --git a/tests/env/.env.test-e2e b/tests/env/.env.test-electron similarity index 84% rename from tests/env/.env.test-e2e rename to tests/env/.env.test-electron index 0cf137dae0..bb5b8b782e 100644 --- a/tests/env/.env.test-e2e +++ b/tests/env/.env.test-electron @@ -1,4 +1,4 @@ -# CI環境でのe2eテスト用の.envファイル。CI時に値が上書きされる。 +# CI環境でのelectronテスト用の.envファイル。CI時に値が上書きされる。 VITE_APP_NAME=voicevox VITE_DEFAULT_ENGINE_INFOS=`[ diff --git a/tests/unit/backend/common/__snapshots__/configManager.spec.ts.snap b/tests/unit/backend/common/__snapshots__/configManager.spec.ts.snap index 27c1b4d8f6..4da839ee6a 100644 --- a/tests/unit/backend/common/__snapshots__/configManager.spec.ts.snap +++ b/tests/unit/backend/common/__snapshots__/configManager.spec.ts.snap @@ -19,7 +19,7 @@ exports[`0.13.0からマイグレーションできる 1`] = ` "enablePreset": false, "enableRubyNotation": false, "engineSettings": { - "074fc39e-678b-4c13-8916-ffca8d505d1d": { + "00000000-0000-0000-0000-000000000000": { "outputSamplingRate": "engineDefault", "useGpu": false, },