diff --git a/README.md b/README.md index 9fd4c57b..ab834944 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,26 @@ The development output is available under: - `./packages/EdgeTranslate/dev/chrome/` +Build the extension, watch for changes, and open it in a Playwright-managed Chromium browser: + +```shell +pnpm dev:browser +``` + +The browser opens the project page automatically and stores its isolated profile under +`./packages/EdgeTranslate/dev/browser-profiles/development/`. Run +`pnpm --filter edge_translate e2e:install` first if the Playwright Chromium browser is not +installed. + +To build the production output and open it without file watching: + +```shell +pnpm preview:browser +``` + +The production preview uses a separate profile under +`./packages/EdgeTranslate/dev/browser-profiles/production/`. + ## Acknowledgement This project continues maintenance work based on the original open source project [Edge Translate](https://github.com/EdgeTranslate/EdgeTranslate). Thanks to the original authors and contributors for their work. diff --git a/docs/README_CN.md b/docs/README_CN.md index 929ea716..ce7b7164 100644 --- a/docs/README_CN.md +++ b/docs/README_CN.md @@ -114,6 +114,25 @@ pnpm dev - `./packages/EdgeTranslate/dev/chrome/` +构建并监听扩展,同时使用 Playwright 管理的 Chromium 启动扩展: + +```shell +pnpm dev:browser +``` + +浏览器会自动打开项目页面,并将独立的用户数据保存在 +`./packages/EdgeTranslate/dev/browser-profiles/development/`。如果尚未安装 Playwright +Chromium,请先运行 `pnpm --filter edge_translate e2e:install`。 + +如需构建并打开不带文件监听的生产版本: + +```shell +pnpm preview:browser +``` + +生产预览使用独立的用户数据目录 +`./packages/EdgeTranslate/dev/browser-profiles/production/`。 + ## 致谢 本项目基于原开源项目 [Edge Translate](https://github.com/EdgeTranslate/EdgeTranslate) 持续维护,感谢原作者与贡献者的工作。 diff --git a/docs/README_RU.md b/docs/README_RU.md index 8e821e5d..133b4cb2 100644 --- a/docs/README_RU.md +++ b/docs/README_RU.md @@ -114,6 +114,26 @@ pnpm dev - `./packages/EdgeTranslate/dev/chrome/` +Чтобы собрать расширение, отслеживать изменения и открыть его в Chromium под управлением +Playwright, выполните: + +```shell +pnpm dev:browser +``` + +Браузер автоматически откроет страницу проекта и сохранит изолированный профиль в каталоге +`./packages/EdgeTranslate/dev/browser-profiles/development/`. Если Chromium для Playwright еще не +установлен, сначала выполните `pnpm --filter edge_translate e2e:install`. + +Чтобы собрать и открыть production-версию без отслеживания файлов, выполните: + +```shell +pnpm preview:browser +``` + +Для production-предпросмотра используется отдельный профиль в каталоге +`./packages/EdgeTranslate/dev/browser-profiles/production/`. + ## Благодарность Этот проект продолжает поддержку на основе исходного открытого проекта [Edge Translate](https://github.com/EdgeTranslate/EdgeTranslate). Спасибо оригинальным авторам и участникам за их работу. diff --git a/docs/README_TW.md b/docs/README_TW.md index 2b641916..acc70786 100644 --- a/docs/README_TW.md +++ b/docs/README_TW.md @@ -114,6 +114,25 @@ pnpm dev - `./packages/EdgeTranslate/dev/chrome/` +建置並監聽擴充套件,同時使用 Playwright 管理的 Chromium 啟動擴充套件: + +```shell +pnpm dev:browser +``` + +瀏覽器會自動開啟專案頁面,並將獨立的使用者資料儲存在 +`./packages/EdgeTranslate/dev/browser-profiles/development/`。如果尚未安裝 Playwright +Chromium,請先執行 `pnpm --filter edge_translate e2e:install`。 + +如需建置並開啟不帶檔案監聽的正式版本: + +```shell +pnpm preview:browser +``` + +正式版預覽使用獨立的使用者資料目錄 +`./packages/EdgeTranslate/dev/browser-profiles/production/`。 + ## 致謝 本專案基於原開源專案 [Edge Translate](https://github.com/EdgeTranslate/EdgeTranslate) 持續維護,感謝原作者與貢獻者的工作。 diff --git a/package.json b/package.json index 1cb875e7..3ab8f04c 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,9 @@ "format:staged": "node scripts/format-staged-files.js", "lint:staged": "node scripts/lint-staged-files.js", "check:staged-eol": "node scripts/check-staged-eol.js", - "dev": "turbo run dev" + "dev": "turbo run dev", + "dev:browser": "pnpm --filter edge_translate run dev:browser", + "preview:browser": "pnpm --filter edge_translate run preview:browser" }, "devDependencies": { "@commitlint/cli": "^13.2.1", diff --git a/packages/EdgeTranslate/package.json b/packages/EdgeTranslate/package.json index 35bb94f3..31c1cb31 100644 --- a/packages/EdgeTranslate/package.json +++ b/packages/EdgeTranslate/package.json @@ -8,6 +8,8 @@ "lint:fix": "pnpm lint --fix", "format": "node ../../scripts/run-eslint.js packages/EdgeTranslate \"src/**/*.js\" \"config/**/*.js\" --fix", "dev": "pnpm --dir ../translators dev:prepare && node ./scripts/build-extension.js dev --browser chrome", + "dev:browser": "pnpm --dir ../translators dev:prepare && node ./scripts/build-extension.js dev --browser chrome --launch-browser", + "preview:browser": "pnpm --dir ../translators build && node ./scripts/build-extension.js preview --browser chrome", "build": "node ./scripts/build-extension.js build --browser chrome", "package": "node -e \"console.log('\\npacking package for chrome')\" && node ./scripts/build-extension.js package --browser chrome", "e2e": "playwright test --config ./test/e2e/playwright.config.js --project=functional", diff --git a/packages/EdgeTranslate/scripts/build-extension-tasks.js b/packages/EdgeTranslate/scripts/build-extension-tasks.js index 5f9e4a66..1d6d6e96 100644 --- a/packages/EdgeTranslate/scripts/build-extension-tasks.js +++ b/packages/EdgeTranslate/scripts/build-extension-tasks.js @@ -20,9 +20,10 @@ const { toPosixPath, } = require("./build-extension-assets"); const { watchAssets } = require("./build-extension-watch"); +const { createManualBrowserSession, runManualBrowser } = require("./manual-browser"); function parseArgs(argv) { - const args = { browser: "chrome", command: argv[0] }; + const args = { browser: "chrome", command: argv[0], launchBrowser: false }; for (let index = 1; index < argv.length; index += 1) { const arg = argv[index]; @@ -32,6 +33,8 @@ function parseArgs(argv) { index += 1; } else if (arg.startsWith("--browser=")) { args.browser = arg.slice("--browser=".length) || args.browser; + } else if (arg === "--launch-browser") { + args.launchBrowser = true; } } @@ -39,7 +42,10 @@ function parseArgs(argv) { } function printUsage() { - console.error("Usage: node scripts/build-extension.js [--browser chrome]"); + console.error( + "Usage: node scripts/build-extension.js " + + "[--browser chrome] [--launch-browser]" + ); } async function buildExtension({ browser }) { @@ -50,13 +56,20 @@ async function buildExtension({ browser }) { patchGeneratedGlobalFallbacks(context); } -async function devExtension({ browser }) { +async function devExtension({ browser, launchBrowser }) { const context = createBuildContext("development", browser); cleanOutput(context); await runAssetBuild(context); await touchHotReloadStamp(context); - await runWebpackWatch(context); + await runWebpackWatch(context, { launchBrowser }); +} + +async function previewExtension({ browser }) { + await buildExtension({ browser }); + + const context = createBuildContext("production", browser); + await runManualBrowser(context.outputDir, context.environment); } async function packageExtension({ browser }) { @@ -118,12 +131,26 @@ function runWebpack(context) { }); } -function runWebpackWatch(context) { +function runWebpackWatch(context, { launchBrowser }) { const watcher = watchAssets(context); + const browserSession = createManualBrowserSession( + launchBrowser, + context.outputDir, + context.environment + ); const compiler = webpack(createWebpackConfig(context)); - const watching = compiler.watch({ aggregateTimeout: 300 }, logWebpackResult); + let shutdown; + const watching = compiler.watch({ aggregateTimeout: 300 }, (error, stats) => { + if (!logWebpackResult(error, stats)) return; + + browserSession.start( + () => shutdown(), + (browserError) => handleBrowserLaunchError(browserError, shutdown) + ); + }); - bindShutdown(watcher, watching); + shutdown = createShutdown({ browserSession, watcher, watching }); + bindShutdown(shutdown); return new Promise(() => {}); } @@ -140,8 +167,10 @@ function logWebpackResult(error, stats) { try { assertWebpackSuccess(error, stats); logWebpackStats(stats); + return true; } catch (buildError) { console.error(buildError.stack || buildError); + return false; } } @@ -227,13 +256,38 @@ function closeCompiler(compiler) { }); } -function bindShutdown(watcher, watching) { - const shutdown = () => { - Promise.all([watcher.close(), closeWatching(watching)]).finally(() => process.exit()); +function handleBrowserLaunchError(error, shutdown) { + console.error(error.stack || error); + shutdown(1); +} + +function createShutdown(resources) { + let shutdownPromise; + + return (exitCode = 0) => { + if (!shutdownPromise) { + shutdownPromise = finishShutdown(resources, exitCode); + } + return shutdownPromise; }; +} + +async function finishShutdown({ browserSession, watcher, watching }, exitCode) { + try { + await Promise.all([browserSession.close(), watcher.close(), closeWatching(watching)]); + } catch (error) { + console.error(error.stack || error); + exitCode = 1; + } + + process.exit(exitCode); +} + +function bindShutdown(shutdown) { + const handleSignal = () => shutdown(); - process.once("SIGINT", shutdown); - process.once("SIGTERM", shutdown); + process.once("SIGINT", handleSignal); + process.once("SIGTERM", handleSignal); } function closeWatching(watching) { @@ -278,5 +332,6 @@ module.exports = { devExtension, packageExtension, parseArgs, + previewExtension, printUsage, }; diff --git a/packages/EdgeTranslate/scripts/build-extension.js b/packages/EdgeTranslate/scripts/build-extension.js index a3cf793e..c0674c8e 100644 --- a/packages/EdgeTranslate/scripts/build-extension.js +++ b/packages/EdgeTranslate/scripts/build-extension.js @@ -3,6 +3,7 @@ const { devExtension, packageExtension, parseArgs, + previewExtension, printUsage, } = require("./build-extension-tasks"); @@ -19,6 +20,11 @@ async function main() { return; } + if (args.command === "preview") { + await previewExtension(args); + return; + } + if (args.command === "package") { await packageExtension(args); return; diff --git a/packages/EdgeTranslate/scripts/manual-browser.js b/packages/EdgeTranslate/scripts/manual-browser.js new file mode 100644 index 00000000..99c6d58c --- /dev/null +++ b/packages/EdgeTranslate/scripts/manual-browser.js @@ -0,0 +1,128 @@ +const path = require("path"); +const { chromium } = require("@playwright/test"); + +const DEFAULT_PAGE_URL = "https://github.com/three-water666/EdgeTranslate"; +const MANUAL_PROFILE_ROOT = path.resolve(__dirname, "../dev/browser-profiles"); +const SERVICE_WORKER_TIMEOUT = 15_000; +const PAGE_NAVIGATION_TIMEOUT = 30_000; + +async function launchManualBrowser(extensionPath, profileName) { + const profileDir = getManualProfileDir(profileName); + const context = await chromium.launchPersistentContext( + profileDir, + createLaunchOptions(extensionPath) + ); + + try { + const serviceWorker = await getExtensionServiceWorker(context); + const extensionId = new URL(serviceWorker.url()).host; + + await openDefaultPage(context); + logBrowserDetails(extensionPath, extensionId, profileDir); + return context; + } catch (error) { + await context.close().catch(() => {}); + throw error; + } +} + +function createManualBrowserSession(enabled, extensionPath, profileName) { + let launchPromise; + + return { + start(onClose, onError) { + if (!enabled || launchPromise) return; + + launchPromise = launchManualBrowser(extensionPath, profileName); + launchPromise.then((context) => context.once("close", onClose), onError); + }, + async close() { + if (!launchPromise) return; + + const context = await launchPromise.catch(() => null); + await context?.close().catch(() => {}); + }, + }; +} + +async function runManualBrowser(extensionPath, profileName) { + const context = await launchManualBrowser(extensionPath, profileName); + await waitForBrowserClose(context); +} + +function waitForBrowserClose(context) { + return new Promise((resolve, reject) => { + const closeBrowser = () => context.close().catch(reject); + const handleClose = () => { + process.off("SIGINT", closeBrowser); + process.off("SIGTERM", closeBrowser); + resolve(); + }; + + process.once("SIGINT", closeBrowser); + process.once("SIGTERM", closeBrowser); + context.once("close", handleClose); + }); +} + +function getManualProfileDir(profileName) { + return path.join(MANUAL_PROFILE_ROOT, profileName); +} + +function createLaunchOptions(extensionPath) { + return { + channel: "chromium", + headless: false, + viewport: null, + args: [ + `--disable-extensions-except=${extensionPath}`, + `--load-extension=${extensionPath}`, + "--start-maximized", + ], + }; +} + +async function getExtensionServiceWorker(context) { + const [existingServiceWorker] = context.serviceWorkers(); + if (existingServiceWorker) return existingServiceWorker; + + return await context.waitForEvent("serviceworker", { timeout: SERVICE_WORKER_TIMEOUT }); +} + +async function openDefaultPage(context) { + const existingProjectPage = context + .pages() + .find((candidate) => candidate.url().startsWith(DEFAULT_PAGE_URL)); + if (existingProjectPage) { + await existingProjectPage.bringToFront(); + return; + } + + const page = context.pages().find((candidate) => candidate.url() === "about:blank"); + const targetPage = page || (await context.newPage()); + + try { + await targetPage.goto(DEFAULT_PAGE_URL, { + timeout: PAGE_NAVIGATION_TIMEOUT, + waitUntil: "domcontentloaded", + }); + } catch (error) { + console.warn(`Could not open ${DEFAULT_PAGE_URL}: ${error.message}`); + } +} + +function logBrowserDetails(extensionPath, extensionId, profileDir) { + console.log(`Manual browser profile: ${profileDir}`); + console.log(`Loaded extension: ${extensionPath}`); + console.log(`Extension ID: ${extensionId}`); + console.log(`Opened page: ${DEFAULT_PAGE_URL}`); + console.log("Close the browser or press Ctrl+C to stop the browser session."); +} + +module.exports = { + DEFAULT_PAGE_URL, + createManualBrowserSession, + getManualProfileDir, + launchManualBrowser, + runManualBrowser, +}; diff --git a/packages/EdgeTranslate/test/unit/scripts/manual_browser.test.js b/packages/EdgeTranslate/test/unit/scripts/manual_browser.test.js new file mode 100644 index 00000000..a2cb80a2 --- /dev/null +++ b/packages/EdgeTranslate/test/unit/scripts/manual_browser.test.js @@ -0,0 +1,91 @@ +const path = require("path"); +const { chromium } = require("@playwright/test"); +const { + DEFAULT_PAGE_URL, + getManualProfileDir, + launchManualBrowser, +} = require("../../../scripts/manual-browser"); + +jest.mock("@playwright/test", () => ({ + chromium: { + launchPersistentContext: jest.fn(), + }, +})); + +describe("manual browser launcher", () => { + let context; + let page; + let serviceWorker; + + beforeEach(() => { + page = { + bringToFront: jest.fn().mockResolvedValue(), + goto: jest.fn().mockResolvedValue(), + url: jest.fn().mockReturnValue("about:blank"), + }; + serviceWorker = { + url: jest.fn().mockReturnValue("chrome-extension://extension-id/background.js"), + }; + context = { + close: jest.fn().mockResolvedValue(), + newPage: jest.fn().mockResolvedValue(page), + pages: jest.fn().mockReturnValue([page]), + serviceWorkers: jest.fn().mockReturnValue([serviceWorker]), + waitForEvent: jest.fn(), + }; + chromium.launchPersistentContext.mockResolvedValue(context); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test("launches headed Chromium with the development extension", async () => { + const extensionPath = path.resolve("project", "dev", "chrome"); + const profileDir = path.resolve(__dirname, "../../../dev/browser-profiles/development"); + + await expect(launchManualBrowser(extensionPath, "development")).resolves.toBe(context); + + expect(chromium.launchPersistentContext).toHaveBeenCalledWith(profileDir, { + channel: "chromium", + headless: false, + viewport: null, + args: [ + `--disable-extensions-except=${extensionPath}`, + `--load-extension=${extensionPath}`, + "--start-maximized", + ], + }); + expect(page.goto).toHaveBeenCalledWith(DEFAULT_PAGE_URL, { + timeout: 30_000, + waitUntil: "domcontentloaded", + }); + expect(getManualProfileDir("production")).toBe( + path.resolve(__dirname, "../../../dev/browser-profiles/production") + ); + }); + + test("waits for the service worker and creates a page when needed", async () => { + context.pages.mockReturnValue([]); + context.serviceWorkers.mockReturnValue([]); + context.waitForEvent.mockResolvedValue(serviceWorker); + + await launchManualBrowser(path.resolve("project", "dev", "chrome"), "development"); + + expect(context.waitForEvent).toHaveBeenCalledWith("serviceworker", { timeout: 15_000 }); + expect(context.newPage).toHaveBeenCalledTimes(1); + expect(page.goto).toHaveBeenCalledWith(DEFAULT_PAGE_URL, expect.any(Object)); + }); + + test("reuses a project page opened by the production extension", async () => { + page.url.mockReturnValue(`${DEFAULT_PAGE_URL}/`); + + await launchManualBrowser(path.resolve("project", "build", "chrome"), "production"); + + expect(page.bringToFront).toHaveBeenCalledTimes(1); + expect(page.goto).not.toHaveBeenCalled(); + expect(context.newPage).not.toHaveBeenCalled(); + }); +});