diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 000000000..9ea1d6716 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,42 @@ +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: + - 'main' + +name: CI E2E +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-test + cancel-in-progress: true +env: + CI: e2e + NODE_OPTIONS: --max-old-space-size=8192 + HUSKY: 0 + +jobs: + e2e: + name: E2E + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Install pnpm + run: npm install -g pnpm@9 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'pnpm' + - name: Install Python setuptools + if: matrix.os == 'macos-latest' + run: brew install python-setuptools + - name: Install appdmg + if: matrix.os == 'macos-latest' + run: npm install -g appdmg + - name: Install dependencies + run: pnpm install + - name: Run e2e + run: pnpm run test:e2e diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9a0e35103..be6ea5114 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,7 +5,7 @@ on: branches: - 'main' -name: CI Test, Typecheck, Build +name: CI Test, Typecheck concurrency: group: ${{ github.workflow }}-${{ github.ref }}-test cancel-in-progress: true @@ -33,26 +33,3 @@ jobs: run: pnpm run typecheck - name: Run test run: pnpm run test - - build: - name: Build - strategy: - matrix: - os: [macos-latest] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - - name: Install pnpm - run: npm install -g pnpm@9 - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: 'pnpm' - - name: Install Python setuptools - run: brew install python-setuptools - - name: Install appdmg - run: npm install -g appdmg - - name: Install dependencies - run: pnpm install - - name: Run build - run: pnpm run build diff --git a/.gitignore b/.gitignore index 72b988d07..8d33c6f3d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ out *.log* .eslintcache .env -resources/report.html +test-results/ diff --git a/.lintstagedrc.mjs b/.lintstagedrc.mjs index 680888c9c..ffbd7fe56 100644 --- a/.lintstagedrc.mjs +++ b/.lintstagedrc.mjs @@ -1,3 +1,7 @@ +/** + * Copyright (c) 2025 Bytedance, Inc. and its affiliates. + * SPDX-License-Identifier: Apache-2.0 + */ export default { '**/*.{ts,tsx}': ['npx prettier --write'], 'src/{main,preload}/**/*.{ts,tsx}': [() => 'npm run typecheck:node'], diff --git a/e2e/app.test.ts b/e2e/app.test.ts new file mode 100644 index 000000000..4ba7fd2c6 --- /dev/null +++ b/e2e/app.test.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2025 Bytedance, Inc. and its affiliates. + * SPDX-License-Identifier: Apache-2.0 + */ +import { + ElectronApplication, + Page, + _electron as electron, + expect, + test, +} from '@playwright/test'; +import { findLatestBuild, parseElectronApp } from 'electron-playwright-helpers'; + +let electronApp: ElectronApplication; +let page: Page; + +test.beforeAll(async () => { + const latestBuild = findLatestBuild(); + const { executable: executablePath, main } = parseElectronApp(latestBuild); + console.log('executablePath:', executablePath, '\nmain:', main); + process.env.CI = 'e2e'; + electronApp = await electron.launch({ + args: [main], + executablePath, + env: { + ...process.env, + CI: 'e2e', + }, + }); + + page = await electronApp.firstWindow(); + electronApp.on('window', async (page) => { + const filename = page.url()?.split('/').pop(); + console.log(`Window opened: ${filename}`); + + // capture errors + page.on('pageerror', (error) => { + console.error(error); + }); + // capture console messages + page.on('console', (msg) => { + console.log(msg.text()); + }); + }); +}); + +test.afterAll(async () => { + await electronApp?.close(); +}); + +test('app can launch', async () => { + await page.waitForLoadState('domcontentloaded'); + + const buttonElement = await page.$('button'); + expect(await buttonElement?.isVisible()).toBe(true); +}); diff --git a/e2e/vitest.config.mts b/e2e/vitest.config.mts new file mode 100644 index 000000000..e28d01b3c --- /dev/null +++ b/e2e/vitest.config.mts @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Bytedance, Inc. and its affiliates. + * SPDX-License-Identifier: Apache-2.0 + */ +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import tsconfigPath from 'vite-tsconfig-paths'; +import { defineProject } from 'vitest/config'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); + +export default defineProject({ + root: __dirname, + test: { + globals: true, + setupFiles: [], + environment: 'node', + includeSource: [resolve(__dirname, '.')], + testTimeout: 15 * 1000, + }, + + plugins: [ + tsconfigPath({ + projects: ['../tsconfig.node.json'], + }), + ], +}); diff --git a/forge.config.ts b/forge.config.ts index da68b9819..718f0854a 100644 --- a/forge.config.ts +++ b/forge.config.ts @@ -154,15 +154,20 @@ const config: ForgeConfig = { new AutoUnpackNativesPlugin({}), // Fuses are used to enable/disable various Electron functionality // at package time, before code signing the application - new FusesPlugin({ - version: FuseVersion.V1, - [FuseV1Options.RunAsNode]: false, - [FuseV1Options.EnableCookieEncryption]: true, - [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false, - [FuseV1Options.EnableNodeCliInspectArguments]: false, - [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true, - [FuseV1Options.OnlyLoadAppFromAsar]: true, - }), + // https://github.com/microsoft/playwright/issues/28669#issuecomment-2268380066 + ...(process.env.CI === 'e2e' + ? [] + : [ + new FusesPlugin({ + version: FuseVersion.V1, + [FuseV1Options.RunAsNode]: false, + [FuseV1Options.EnableCookieEncryption]: true, + [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false, + [FuseV1Options.EnableNodeCliInspectArguments]: false, + [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true, + [FuseV1Options.OnlyLoadAppFromAsar]: true, + }), + ]), ], }; diff --git a/package.json b/package.json index ec4c4288d..6fe4dce69 100644 --- a/package.json +++ b/package.json @@ -19,11 +19,15 @@ "debug:w": "electron-vite dev -w --sourcemap --remote-debugging-port=9222", "dev:w": "electron-vite dev -w", "asar:analyze": "asar extract out/UI\\ TARS-darwin-arm64/UI\\ TARS.app/Contents/Resources/app.asar ./dist/asar", - "package": "electron-forge package dist", + "package": "electron-forge package", "clean": "rimraf dist out", "test": "vitest", + "pretest:e2e": "npm run build:dist && cross-env CI=e2e npm run package", + "test:e2e": "playwright test", "build:deps": "pnpm --filter \"!ui-tars-desktop,ui-tars-desktop...\" build && cd packages/visualizer && pnpm install --ignore-workspace", - "build": "pnpm run clean && pnpm run build:deps && pnpm run typecheck && cross-env NODE_ENV=production electron-vite build && electron-forge make --enable-logging", + "build:dist": "cross-env NODE_ENV=production electron-vite build", + "build": "npm run clean && npm run build:deps && npm run typecheck && cross-env NODE_ENV=production electron-vite build && electron-forge make --enable-logging", + "make": "electron-forge make", "publish:mac-x64": "npm run build && electron-forge publish --arch=x64 --platform=darwin", "publish:mac-arm64": "npm run build && electron-forge publish --arch=arm64 --platform=darwin", "publish:win32": "npm run build && electron-forge publish --arch=x64 --platform=win32", @@ -32,9 +36,8 @@ "prepare": "husky" }, "dependencies": { - "@computer-use/nut-js": "^4.2.0", "@computer-use/node-mac-permissions": "2.2.2", - "mac-screen-capture-permissions": "2.1.0", + "@computer-use/nut-js": "^4.2.0", "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^3.0.0", "@electron/notarize": "^2.3.2", @@ -50,6 +53,7 @@ "electron-updater": "^6.1.4", "js-yaml": "^4.1.0", "lodash-es": "4.17.21", + "mac-screen-capture-permissions": "2.1.0", "ms": "^2.1.3", "openai": "4.73.0", "react-use": "^17.6.0", @@ -95,6 +99,7 @@ "@electron/fuses": "^1.8.0", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", + "@playwright/test": "^1.49.1", "@trivago/prettier-plugin-sort-imports": "^5.2.1", "@types/node": "^20.14.8", "@types/react": "^18.3.3", @@ -107,6 +112,7 @@ "cross-env": "^7.0.3", "electron": "34.0.0", "electron-packager-languages": "0.5.0", + "electron-playwright-helpers": "^1.7.1", "electron-vite": "^2.3.0", "eslint": "^8.57.0", "eslint-plugin-import": "^2.25.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..30eb8af01 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) 2025 Bytedance, Inc. and its affiliates. + * SPDX-License-Identifier: Apache-2.0 + */ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + retries: process.env.CI ? 2 : 0, + workers: 1, + use: { + trace: 'on-first-retry', + }, + timeout: 60000, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9864ee015..ac14a2f60 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,6 +147,9 @@ importers: '@emotion/styled': specifier: ^11.13.0 version: 11.14.0(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1) + '@playwright/test': + specifier: ^1.49.1 + version: 1.49.1 '@trivago/prettier-plugin-sort-imports': specifier: ^5.2.1 version: 5.2.1(@vue/compiler-sfc@3.5.13)(prettier@3.4.2) @@ -183,6 +186,9 @@ importers: electron-packager-languages: specifier: 0.5.0 version: 0.5.0 + electron-playwright-helpers: + specifier: ^1.7.1 + version: 1.7.1 electron-vite: specifier: ^2.3.0 version: 2.3.0(vite@5.4.11(@types/node@20.17.11)(sass-embedded@1.83.1)(sass@1.83.0)(terser@5.37.0)) @@ -1824,6 +1830,11 @@ packages: resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@playwright/test@1.49.1': + resolution: {integrity: sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==} + engines: {node: '>=18'} + hasBin: true + '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} @@ -3059,6 +3070,9 @@ packages: resolution: {integrity: sha512-ryJsVXgHq0+7eZpJ+YSUQNYUnH4yPq2J4gXtmP9HEq8N6PHtygLEmohKMm4VrwI5qTir4HRCxy+O1UNo8mbwgg==} engines: {node: '>6.0.0'} + electron-playwright-helpers@1.7.1: + resolution: {integrity: sha512-S9mo7LfpERgub2WIuYVPpib4XKFeAqBP+mxYf5Bv7E0B5GUB+LUbSj6Fpu39h18Ar635Nf9nQYTmypjuvaYJng==} + electron-squirrel-startup@1.0.1: resolution: {integrity: sha512-sTfFIHGku+7PsHLJ7v0dRcZNkALrV+YEozINTW8X1nM//e5O3L+rfYuvSW00lmGHnYmUjARZulD8F2V8ISI9RA==} @@ -3584,6 +3598,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4988,6 +5007,16 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + playwright-core@1.49.1: + resolution: {integrity: sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.49.1: + resolution: {integrity: sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==} + engines: {node: '>=18'} + hasBin: true + plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} @@ -8393,6 +8422,10 @@ snapshots: '@pkgr/core@0.1.1': {} + '@playwright/test@1.49.1': + dependencies: + playwright: 1.49.1 + '@popperjs/core@2.11.8': {} '@rollup/rollup-android-arm-eabi@4.29.1': @@ -9785,6 +9818,10 @@ snapshots: dependencies: rimraf: 2.6.3 + electron-playwright-helpers@1.7.1: + dependencies: + '@electron/asar': 3.2.18 + electron-squirrel-startup@1.0.1: dependencies: debug: 2.6.9 @@ -10591,6 +10628,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -12063,6 +12103,14 @@ snapshots: dependencies: find-up: 4.1.0 + playwright-core@1.49.1: {} + + playwright@1.49.1: + dependencies: + playwright-core: 1.49.1 + optionalDependencies: + fsevents: 2.3.2 + plist@3.1.0: dependencies: '@xmldom/xmldom': 0.8.10 diff --git a/scripts/xxx.jpg b/scripts/xxx.jpg deleted file mode 100644 index b4e9b7309..000000000 Binary files a/scripts/xxx.jpg and /dev/null differ diff --git a/src/main/env.ts b/src/main/env.ts index c58048c27..0036ecfa6 100644 --- a/src/main/env.ts +++ b/src/main/env.ts @@ -15,6 +15,7 @@ export const forceDownload = !!process.env.UPGRADE_EXTENSIONS; export const port = process.env.PORT || 1212; export const startMinimized = process.env.START_MINIMIZED; export const rendererUrl = process.env.ELECTRON_RENDERER_URL; +export const isE2eTest = process.env.CI === 'e2e'; export const vlmProvider = process.env.VLM_PROVIDER; export const vlmBaseUrl = process.env.VLM_BASE_URL; diff --git a/src/main/main.ts b/src/main/main.ts index 9e4d11fdd..f05c844f1 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -88,6 +88,7 @@ const initializeApp = async () => { await loadDevDebugTools(); // } + logger.info('createTray'); // Tray await createTray(); @@ -97,6 +98,7 @@ const initializeApp = async () => { launcherWindowIns.show(); }); + logger.info('createMainWindow'); const mainWindow = createMainWindow(); const settingsWindow = createSettingsWindow(); @@ -104,6 +106,8 @@ const initializeApp = async () => { // eslint-disable-next-line new AppUpdater(); + logger.info('mainZustandBridge'); + const { unsubscribe } = mainZustandBridge( ipcMain, store, @@ -120,6 +124,8 @@ const initializeApp = async () => { ); app.on('quit', unsubscribe); + + logger.info('initializeApp end'); }; /** @@ -147,5 +153,7 @@ app }); await initializeApp(); + + logger.info('app.whenReady end'); }) .catch(console.log); diff --git a/src/main/utils/systemPermissions.ts b/src/main/utils/systemPermissions.ts index bf34411f7..83a2f6411 100644 --- a/src/main/utils/systemPermissions.ts +++ b/src/main/utils/systemPermissions.ts @@ -9,6 +9,7 @@ import { openSystemPreferences, } from 'mac-screen-capture-permissions'; +import * as env from '@main/env'; import { logger } from '@main/logger'; let hasScreenRecordingPermission = false; @@ -67,6 +68,13 @@ export const ensurePermissions = (): { screenCapture: boolean; accessibility: boolean; } => { + if (env.isE2eTest) { + return { + screenCapture: true, + accessibility: true, + }; + } + logger.info( '[ensurePermissions] hasScreenRecordingPermission', hasScreenRecordingPermission, diff --git a/src/main/window/index.ts b/src/main/window/index.ts index b121cc32c..683a7d8a3 100644 --- a/src/main/window/index.ts +++ b/src/main/window/index.ts @@ -10,12 +10,6 @@ import { createWindow } from './createWindow'; let mainWindow: BrowserWindow | null = null; let settingsWindow: BrowserWindow | null = null; -let fadeInterval: NodeJS.Timeout | null = null; -let showTimeout: NodeJS.Timeout | null = null; - -const FADE_STEP = 0.1; -const FADE_INTERVAL = 16; -const SHOW_DELAY = 500; export function showInactive() { if (mainWindow) { @@ -30,72 +24,6 @@ export function show() { } } -function executeFade(show: boolean, resolve: () => void) { - if (!mainWindow) { - resolve(); - return; - } - - if (show) { - mainWindow.setOpacity(0); - mainWindow.showInactive(); - } - - let opacity = show ? 0 : 1; - - fadeInterval = setInterval(() => { - if (!mainWindow) { - if (fadeInterval) clearInterval(fadeInterval); - resolve(); - return; - } - - opacity = show ? opacity + FADE_STEP : opacity - FADE_STEP; - opacity = Math.min(Math.max(opacity, 0), 1); - mainWindow.setOpacity(opacity); - - if ((show && opacity >= 1) || (!show && opacity <= 0)) { - if (fadeInterval) clearInterval(fadeInterval); - if (!show) mainWindow.hide(); - resolve(); - } - }, FADE_INTERVAL); -} - -function fadeWindow(show: boolean, immediate = false): Promise { - return new Promise((resolve) => { - if (!mainWindow) { - resolve(); - return; - } - - if (fadeInterval) { - clearInterval(fadeInterval); - } - - if (!show) { - if (showTimeout) { - clearTimeout(showTimeout); - showTimeout = null; - } - executeFade(show, resolve); - return; - } - - if (showTimeout) { - clearTimeout(showTimeout); - } - - if (immediate) { - executeFade(show, resolve); - } else { - showTimeout = setTimeout(() => { - executeFade(show, resolve); - }, SHOW_DELAY); - } - }); -} - export function createMainWindow() { ipcMain.removeHandler('minimize-window'); ipcMain.removeHandler('maximize-window'); @@ -122,7 +50,6 @@ export function createMainWindow() { ipcMain.handle('close-window', async () => { if (mainWindow) { - await fadeWindow(false); mainWindow.close(); } }); diff --git a/tsconfig.node.json b/tsconfig.node.json index 221b00827..fc1070b7e 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -8,7 +8,8 @@ "src/main/**/*", "src/preload/**/*", "e2e/**/*", - "src/shared/**/*" + "src/shared/**/*", + "playwright.config.ts" ], "compilerOptions": { "composite": true,