diff --git a/bin/README.md b/bin/README.md index b98f569..55489fb 100644 --- a/bin/README.md +++ b/bin/README.md @@ -47,6 +47,8 @@ pake [url] [options] The packaged application will be located in the current working directory by default. The first packaging might take some time due to environment configuration. Please be patient. +> **macOS Output**: On macOS, Pake creates DMG installers by default. To create `.app` bundles for testing (to avoid user interaction), set the environment variable `PAKE_CREATE_APP=1`. +> > **Note**: Packaging requires the Rust environment. If Rust is not installed, you will be prompted for installation confirmation. In case of installation failure or timeout, you can [install it manually](https://www.rust-lang.org/tools/install). ### [url] @@ -279,7 +281,7 @@ Supports both comma-separated and multiple option formats: #### [proxy-url] -Set proxy server for all network requests. Supports HTTP, HTTPS, and SOCKS5. +Set proxy server for all network requests. Supports HTTP, HTTPS, and SOCKS5. Available on Windows and Linux. On macOS, requires macOS 14+. ```shell --proxy-url http://127.0.0.1:7890 diff --git a/bin/README_CN.md b/bin/README_CN.md index f7bf803..ee3341c 100644 --- a/bin/README_CN.md +++ b/bin/README_CN.md @@ -47,6 +47,8 @@ pake [url] [options] 应用程序的打包结果将默认保存在当前工作目录。由于首次打包需要配置环境,这可能需要一些时间,请耐心等待。 +> **macOS 输出**:在 macOS 上,Pake 默认创建 DMG 安装程序。如需创建 `.app` 包进行测试(避免用户交互),请设置环境变量 `PAKE_CREATE_APP=1`。 +> > **注意**:打包过程需要使用 `Rust` 环境。如果您没有安装 `Rust`,系统会提示您是否要安装。如果遇到安装失败或超时的问题,您可以 [手动安装](https://www.rust-lang.org/tools/install)。 ### [url] @@ -281,7 +283,7 @@ pake [url] [options] #### [proxy-url] -为所有网络请求设置代理服务器。支持 HTTP、HTTPS 和 SOCKS5。 +为所有网络请求设置代理服务器。支持 HTTP、HTTPS 和 SOCKS5。在 Windows 和 Linux 上可用。在 macOS 上需要 macOS 14+。 ```shell --proxy-url http://127.0.0.1:7890 diff --git a/bin/builders/BaseBuilder.ts b/bin/builders/BaseBuilder.ts index f6c1445..a22db05 100644 --- a/bin/builders/BaseBuilder.ts +++ b/bin/builders/BaseBuilder.ts @@ -118,8 +118,44 @@ export default abstract class BaseBuilder { abstract getFileName(): string; protected getBuildCommand(): string { - // the debug option should support `--debug` and `--release` - return this.options.debug ? 'npm run build:debug' : 'npm run build'; + const baseCommand = this.options.debug + ? 'npm run build:debug' + : 'npm run build'; + + // Use temporary config directory to avoid modifying source files + const configPath = path.join( + npmDirectory, + 'src-tauri', + '.pake', + 'tauri.conf.json', + ); + let fullCommand = `${baseCommand} -- -c "${configPath}"`; + + // For macOS, use app bundles by default unless DMG is explicitly requested + if (IS_MAC && this.options.targets === 'app') { + fullCommand += ' --bundles app'; + } + + // Add macos-proxy feature for modern macOS (Darwin 23+ = macOS 14+) + if (IS_MAC) { + const macOSVersion = this.getMacOSMajorVersion(); + if (macOSVersion >= 23) { + fullCommand += ' --features macos-proxy'; + } + } + + return fullCommand; + } + + private getMacOSMajorVersion(): number { + try { + const os = require('os'); + const release = os.release(); + const majorVersion = parseInt(release.split('.')[0], 10); + return majorVersion; + } catch (error) { + return 0; // Disable proxy feature if version detection fails + } } protected getBasePath(): string { @@ -132,10 +168,13 @@ export default abstract class BaseBuilder { fileName: string, fileType: string, ): string { + // For app bundles on macOS, the directory is 'macos', not 'app' + const bundleDir = + fileType.toLowerCase() === 'app' ? 'macos' : fileType.toLowerCase(); return path.join( npmDirectory, this.getBasePath(), - fileType.toLowerCase(), + bundleDir, `${fileName}.${fileType}`, ); } diff --git a/bin/builders/MacBuilder.ts b/bin/builders/MacBuilder.ts index 93b49fa..52f1b5e 100644 --- a/bin/builders/MacBuilder.ts +++ b/bin/builders/MacBuilder.ts @@ -5,11 +5,24 @@ import BaseBuilder from './BaseBuilder'; export default class MacBuilder extends BaseBuilder { constructor(options: PakeAppOptions) { super(options); - this.options.targets = 'dmg'; + // Use DMG by default for distribution + // Only create app bundles for testing to avoid user interaction + if (process.env.PAKE_CREATE_APP === '1') { + this.options.targets = 'app'; + } else { + this.options.targets = 'dmg'; + } } getFileName(): string { const { name } = this.options; + + // For app bundles, use simple name without version/arch + if (this.options.targets === 'app') { + return name; + } + + // For DMG files, use versioned filename let arch: string; if (this.options.multiArch) { arch = 'universal'; diff --git a/bin/helpers/merge.ts b/bin/helpers/merge.ts index fb288f5..906258a 100644 --- a/bin/helpers/merge.ts +++ b/bin/helpers/merge.ts @@ -1,17 +1,42 @@ import path from 'path'; import fsExtra from 'fs-extra'; -import { npmDirectory } from '@/utils/dir'; import combineFiles from '@/utils/combine'; import logger from '@/options/logger'; import { PakeAppOptions, PlatformMap } from '@/types'; -import { tauriConfigDirectory } from '@/utils/dir'; +import { tauriConfigDirectory, npmDirectory } from '@/utils/dir'; export async function mergeConfig( url: string, options: PakeAppOptions, tauriConf: any, ) { + // Ensure .pake directory exists and copy source templates if needed + const srcTauriDir = path.join(npmDirectory, 'src-tauri'); + await fsExtra.ensureDir(tauriConfigDirectory); + + // Copy source config files to .pake directory (as templates) + const sourceFiles = [ + 'tauri.conf.json', + 'tauri.macos.conf.json', + 'tauri.windows.conf.json', + 'tauri.linux.conf.json', + 'pake.json', + ]; + + await Promise.all( + sourceFiles.map(async (file) => { + const sourcePath = path.join(srcTauriDir, file); + const destPath = path.join(tauriConfigDirectory, file); + + if ( + (await fsExtra.pathExists(sourcePath)) && + !(await fsExtra.pathExists(destPath)) + ) { + await fsExtra.copy(sourcePath, destPath); + } + }), + ); const { width, height, diff --git a/bin/utils/dir.ts b/bin/utils/dir.ts index 8276148..c261667 100644 --- a/bin/utils/dir.ts +++ b/bin/utils/dir.ts @@ -7,7 +7,8 @@ const currentModulePath = fileURLToPath(import.meta.url); // Resolve the parent directory of the current module export const npmDirectory = path.join(path.dirname(currentModulePath), '..'); -export const tauriConfigDirectory = - process.env.NODE_ENV === 'development' - ? path.join(npmDirectory, 'src-tauri', '.pake') - : path.join(npmDirectory, 'src-tauri'); +export const tauriConfigDirectory = path.join( + npmDirectory, + 'src-tauri', + '.pake', +); diff --git a/dist/cli.js b/dist/cli.js index 15fc7d4..3b4aea0 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -50,7 +50,7 @@ var files = [ var scripts = { start: "npm run dev", dev: "npm run tauri dev", - build: "npm run tauri build --release", + build: "npm run tauri build --", "build:debug": "npm run tauri build -- --debug", "build:mac": "npm run tauri build -- --target universal-apple-darwin", "build:config": "chmod +x script/app_config.mjs && node script/app_config.mjs", @@ -59,8 +59,10 @@ var scripts = { cli: "rollup -c rollup.config.js --watch", "cli:dev": "cross-env NODE_ENV=development rollup -c rollup.config.js -w", "cli:build": "cross-env NODE_ENV=production rollup -c rollup.config.js", - test: "npm run cli:build && node tests/index.js", + test: "npm run cli:build && PAKE_CREATE_APP=1 node tests/index.js", format: "npx prettier --write . --ignore-unknown && cd src-tauri && cargo fmt --verbose", + "hooks:setup": "bash .githooks/setup.sh", + postinstall: "npm run hooks:setup", prepublishOnly: "npm run cli:build" }; var type = "module"; @@ -133,7 +135,8 @@ var windows = [ dark_mode: false, activation_shortcut: "", disabled_web_shortcuts: false, - hide_on_close: true + hide_on_close: true, + incognito: false } ]; var user_agent = { @@ -222,10 +225,10 @@ var MacConf = { bundle: bundle$1 }; -var productName = "we-read"; +var productName = "weekly"; var bundle = { icon: [ - "png/weekly.png" + "png/weekly_512.png" ], active: true, linux: { @@ -312,20 +315,24 @@ const IS_LINUX = platform$1 === 'linux'; const currentModulePath = fileURLToPath(import.meta.url); // Resolve the parent directory of the current module const npmDirectory = path.join(path.dirname(currentModulePath), '..'); -const tauriConfigDirectory = path.join(npmDirectory, 'src-tauri'); +const tauriConfigDirectory = path.join(npmDirectory, 'src-tauri', '.pake'); -async function shellExec(command) { +async function shellExec(command, timeout = 300000) { try { const { exitCode } = await execa(command, { cwd: npmDirectory, stdio: 'inherit', shell: true, + timeout, }); return exitCode; } catch (error) { const exitCode = error.exitCode ?? 'unknown'; const errorMessage = error.message || 'Unknown error occurred'; + if (error.timedOut) { + throw new Error(`Command timed out after ${timeout}ms: "${command}". Try increasing timeout or check network connectivity.`); + } throw new Error(`Error occurred while executing command "${command}". Exit code: ${exitCode}. Details: ${errorMessage}`); } } @@ -438,7 +445,19 @@ async function combineFiles(files, output) { } async function mergeConfig(url, options, tauriConf) { - const { width, height, fullscreen, hideTitleBar, alwaysOnTop, appVersion, darkMode, disabledWebShortcuts, activationShortcut, userAgent, showSystemTray, systemTrayIcon, useLocalFile, identifier, name, resizable = true, inject, proxyUrl, installerLanguage, hideOnClose, } = options; + // Ensure .pake directory exists and copy source templates if needed + const srcTauriDir = path.join(npmDirectory, 'src-tauri'); + await fsExtra.ensureDir(tauriConfigDirectory); + // Copy source config files to .pake directory (as templates) + const sourceFiles = ['tauri.conf.json', 'tauri.macos.conf.json', 'tauri.windows.conf.json', 'tauri.linux.conf.json', 'pake.json']; + await Promise.all(sourceFiles.map(async (file) => { + const sourcePath = path.join(srcTauriDir, file); + const destPath = path.join(tauriConfigDirectory, file); + if (await fsExtra.pathExists(sourcePath) && !(await fsExtra.pathExists(destPath))) { + await fsExtra.copy(sourcePath, destPath); + } + })); + const { width, height, fullscreen, hideTitleBar, alwaysOnTop, appVersion, darkMode, disabledWebShortcuts, activationShortcut, userAgent, showSystemTray, systemTrayIcon, useLocalFile, identifier, name, resizable = true, inject, proxyUrl, installerLanguage, hideOnClose, incognito, title, } = options; const { platform } = process; // Set Windows parameters. const tauriConfWindowOptions = { @@ -452,6 +471,8 @@ async function mergeConfig(url, options, tauriConf) { dark_mode: darkMode, disabled_web_shortcuts: disabledWebShortcuts, hide_on_close: hideOnClose, + incognito: incognito, + title: title || null, }; Object.assign(tauriConf.pake.windows[0], { url, ...tauriConfWindowOptions }); tauriConf.productName = name; @@ -650,14 +671,16 @@ class BaseBuilder { const registryOption = isChina ? ' --registry=https://registry.npmmirror.com' : ''; + // Windows环境下需要更长的超时时间 + const timeout = process.platform === 'win32' ? 600000 : 300000; if (isChina) { logger.info('✺ Located in China, using npm/rsProxy CN mirror.'); const projectCnConf = path.join(tauriSrcPath, 'rust_proxy.toml'); await fsExtra.copy(projectCnConf, projectConf); - await shellExec(`cd "${npmDirectory}" && ${packageManager} install${registryOption}`); + await shellExec(`cd "${npmDirectory}" && ${packageManager} install${registryOption}`, timeout); } else { - await shellExec(`cd "${npmDirectory}" && ${packageManager} install`); + await shellExec(`cd "${npmDirectory}" && ${packageManager} install`, timeout); } spinner.succeed(chalk.green('Package installed!')); if (!tauriTargetPathExists) { @@ -691,25 +714,66 @@ class BaseBuilder { return target; } getBuildCommand() { - // the debug option should support `--debug` and `--release` - return this.options.debug ? 'npm run build:debug' : 'npm run build'; + const baseCommand = this.options.debug + ? 'npm run build:debug' + : 'npm run build'; + // Use temporary config directory to avoid modifying source files + const configPath = path.join(npmDirectory, 'src-tauri', '.pake', 'tauri.conf.json'); + let fullCommand = `${baseCommand} -- -c "${configPath}"`; + // For macOS, use app bundles by default unless DMG is explicitly requested + if (IS_MAC && this.options.targets === 'app') { + fullCommand += ' --bundles app'; + } + // Add macos-proxy feature for modern macOS (Darwin 23+ = macOS 14+) + if (IS_MAC) { + const macOSVersion = this.getMacOSMajorVersion(); + if (macOSVersion >= 23) { + fullCommand += ' --features macos-proxy'; + } + } + return fullCommand; + } + getMacOSMajorVersion() { + try { + const os = require('os'); + const release = os.release(); + const majorVersion = parseInt(release.split('.')[0], 10); + return majorVersion; + } + catch (error) { + return 0; // Disable proxy feature if version detection fails + } } getBasePath() { const basePath = this.options.debug ? 'debug' : 'release'; return `src-tauri/target/${basePath}/bundle/`; } getBuildAppPath(npmDirectory, fileName, fileType) { - return path.join(npmDirectory, this.getBasePath(), fileType.toLowerCase(), `${fileName}.${fileType}`); + // For app bundles on macOS, the directory is 'macos', not 'app' + const bundleDir = fileType.toLowerCase() === 'app' ? 'macos' : fileType.toLowerCase(); + return path.join(npmDirectory, this.getBasePath(), bundleDir, `${fileName}.${fileType}`); } } class MacBuilder extends BaseBuilder { constructor(options) { super(options); - this.options.targets = 'dmg'; + // Use DMG by default for distribution + // Only create app bundles for testing to avoid user interaction + if (process.env.PAKE_CREATE_APP === '1') { + this.options.targets = 'app'; + } + else { + this.options.targets = 'dmg'; + } } getFileName() { const { name } = this.options; + // For app bundles, use simple name without version/arch + if (this.options.targets === 'app') { + return name; + } + // For DMG files, use versioned filename let arch; if (this.options.multiArch) { arch = 'universal'; @@ -816,6 +880,7 @@ const DEFAULT_PAKE_OPTIONS = { inject: [], installerLanguage: 'en-US', hideOnClose: true, + incognito: false, }; async function checkUpdateTips() { @@ -1059,6 +1124,8 @@ program .addOption(new Option('--hide-on-close', 'Hide window on close instead of exiting') .default(DEFAULT_PAKE_OPTIONS.hideOnClose) .hideHelp()) + .addOption(new Option('--title ', 'Window title').hideHelp()) + .addOption(new Option('--incognito', 'Launch app in incognito/private mode').default(DEFAULT_PAKE_OPTIONS.incognito)) .addOption(new Option('--installer-language ', 'Installer language') .default(DEFAULT_PAKE_OPTIONS.installerLanguage) .hideHelp()) diff --git a/package.json b/package.json index 27b3e56..20c4c57 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "scripts": { "start": "npm run dev", "dev": "npm run tauri dev", - "build": "npm run tauri build --release", + "build": "npm run tauri build --", "build:debug": "npm run tauri build -- --debug", "build:mac": "npm run tauri build -- --target universal-apple-darwin", "build:config": "chmod +x script/app_config.mjs && node script/app_config.mjs", @@ -40,7 +40,7 @@ "cli": "rollup -c rollup.config.js --watch", "cli:dev": "cross-env NODE_ENV=development rollup -c rollup.config.js -w", "cli:build": "cross-env NODE_ENV=production rollup -c rollup.config.js", - "test": "npm run cli:build && node tests/index.js", + "test": "npm run cli:build && PAKE_CREATE_APP=1 node tests/index.js", "format": "npx prettier --write . --ignore-unknown && cd src-tauri && cargo fmt --verbose", "hooks:setup": "bash .githooks/setup.sh", "postinstall": "npm run hooks:setup", diff --git a/tests/e2e.test.js b/tests/e2e.test.js new file mode 100644 index 0000000..c466d76 --- /dev/null +++ b/tests/e2e.test.js @@ -0,0 +1,448 @@ +#!/usr/bin/env node + +/** + * End-to-End (E2E) Tests for Pake CLI + * + * These tests perform complete packaging operations and verify + * the entire build pipeline works correctly. They include cleanup + * to avoid leaving artifacts on the system. + */ + +import { spawn, execSync } from "child_process"; +import fs from "fs"; +import path from "path"; +import config, { TIMEOUTS, TEST_URLS, TEST_NAMES } from "./test.config.js"; +import ora from "ora"; + +class E2ETestRunner { + constructor() { + this.tests = []; + this.results = []; + this.generatedFiles = []; + this.tempDirectories = []; + } + + addTest(name, testFn, timeout = TIMEOUTS.LONG) { + this.tests.push({ name, testFn, timeout }); + } + + async runAll() { + console.log("🚀 End-to-End Tests"); + console.log("===================\n"); + + try { + for (const [index, test] of this.tests.entries()) { + const spinner = ora(`Running ${test.name}...`).start(); + + try { + const result = await Promise.race([ + test.testFn(), + new Promise((_, reject) => + setTimeout(() => reject(new Error("Timeout")), test.timeout), + ), + ]); + + if (result) { + spinner.succeed(`${index + 1}. ${test.name}: PASS`); + this.results.push({ name: test.name, passed: true }); + } else { + spinner.fail(`${index + 1}. ${test.name}: FAIL`); + this.results.push({ name: test.name, passed: false }); + } + } catch (error) { + spinner.fail(`${index + 1}. ${test.name}: ERROR - ${error.message}`); + this.results.push({ + name: test.name, + passed: false, + error: error.message, + }); + } + } + } finally { + // Always cleanup generated files + await this.cleanup(); + } + + this.showSummary(); + } + + async cleanup() { + const cleanupSpinner = ora("Cleaning up generated files...").start(); + + try { + // Remove generated app files + for (const file of this.generatedFiles) { + if (fs.existsSync(file)) { + if (fs.statSync(file).isDirectory()) { + fs.rmSync(file, { recursive: true, force: true }); + } else { + fs.unlinkSync(file); + } + } + } + + // Remove temporary directories + for (const dir of this.tempDirectories) { + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + + // Clean up .pake directory if it's a test run + const pakeDir = path.join(config.PROJECT_ROOT, "src-tauri", ".pake"); + if (fs.existsSync(pakeDir)) { + fs.rmSync(pakeDir, { recursive: true, force: true }); + } + + // Remove any test app files in current directory + const projectFiles = fs.readdirSync(config.PROJECT_ROOT); + const testFilePatterns = ["Weekly", "TestApp", "E2ETest"]; + + const allTestFiles = projectFiles.filter( + (file) => + testFilePatterns.some((pattern) => file.startsWith(pattern)) && + (file.endsWith(".app") || + file.endsWith(".exe") || + file.endsWith(".deb") || + file.endsWith(".dmg")), + ); + + for (const file of allTestFiles) { + const fullPath = path.join(config.PROJECT_ROOT, file); + if (fs.existsSync(fullPath)) { + if (fs.statSync(fullPath).isDirectory()) { + fs.rmSync(fullPath, { recursive: true, force: true }); + } else { + fs.unlinkSync(fullPath); + } + } + } + + // Remove test-generated icon files (only specific test-generated files) + const iconsDir = path.join(config.PROJECT_ROOT, "src-tauri", "icons"); + if (fs.existsSync(iconsDir)) { + const iconFiles = fs.readdirSync(iconsDir); + const testIconFiles = iconFiles.filter( + (file) => + (file.toLowerCase().startsWith("e2etest") || + file.toLowerCase().startsWith("testapp") || + file.toLowerCase() === "test.icns") && + file.endsWith(".icns"), + ); + + for (const file of testIconFiles) { + const fullPath = path.join(iconsDir, file); + if (fs.existsSync(fullPath)) { + fs.unlinkSync(fullPath); + } + } + } + + cleanupSpinner.succeed("Cleanup completed"); + } catch (error) { + cleanupSpinner.fail(`Cleanup failed: ${error.message}`); + } + } + + showSummary() { + const passed = this.results.filter((r) => r.passed).length; + const total = this.results.length; + + console.log(`\n📊 E2E Test Results: ${passed}/${total} passed`); + + if (passed === total) { + console.log("🎉 All E2E tests passed!"); + } else { + console.log("❌ Some E2E tests failed"); + this.results + .filter((r) => !r.passed) + .forEach((r) => console.log(` - ${r.name}: ${r.error || "FAIL"}`)); + } + } + + registerFile(filePath) { + this.generatedFiles.push(filePath); + } + + registerDirectory(dirPath) { + this.tempDirectories.push(dirPath); + } +} + +// Create test runner instance +const runner = new E2ETestRunner(); + +// Add tests +runner.addTest( + "Weekly App Full Build (Debug Mode)", + async () => { + return new Promise((resolve, reject) => { + const testName = "E2ETestWeekly"; + const command = `node "${config.CLI_PATH}" "${TEST_URLS.WEEKLY}" --name "${testName}" --debug`; + + const child = spawn(command, { + shell: true, + cwd: config.PROJECT_ROOT, + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + PAKE_E2E_TEST: "1", + PAKE_CREATE_APP: "1", + }, + }); + + let stdout = ""; + let stderr = ""; + + child.stdout.on("data", (data) => { + stdout += data.toString(); + }); + + child.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + child.on("close", (code) => { + // Register potential output files for cleanup (app bundles only for E2E tests) + const appFile = path.join(config.PROJECT_ROOT, `${testName}.app`); + runner.registerFile(appFile); + + // Check if build got to the bundling stage (indicates successful compile) + const buildingApp = + stdout.includes("Bundling") || + stdout.includes("Built application at:"); + const compilationSuccess = + stdout.includes("Finished") && stdout.includes("target(s)"); + + // For E2E tests, we care more about compilation success than packaging + if (buildingApp || compilationSuccess) { + resolve(true); + return; + } + + // Check actual file existence as fallback + const outputExists = fs.existsSync(appFile); + + if (outputExists) { + resolve(true); + } else if (code === 0) { + reject( + new Error("Build completed but no clear success indicators found"), + ); + } else { + // Only fail if it's a genuine compilation error + if (stderr.includes("error[E") || stderr.includes("cannot find")) { + reject(new Error(`Compilation failed: ${stderr.slice(0, 200)}...`)); + } else { + // Packaging error - check if compilation was successful + resolve(compilationSuccess); + } + } + }); + + child.on("error", (error) => { + reject(new Error(`Process error: ${error.message}`)); + }); + + // Send empty input to handle any prompts + child.stdin.end(); + }); + }, + TIMEOUTS.LONG, +); + +runner.addTest( + "Weekly App Quick Build Verification", + async () => { + return new Promise((resolve, reject) => { + const testName = "E2ETestQuick"; + const command = `node "${config.CLI_PATH}" "${TEST_URLS.WEEKLY}" --name "${testName}" --debug --width 800 --height 600`; + + const child = spawn(command, { + shell: true, + cwd: config.PROJECT_ROOT, + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + PAKE_E2E_TEST: "1", + PAKE_CREATE_APP: "1", + }, + }); + + let buildStarted = false; + + child.stdout.on("data", (data) => { + const output = data.toString(); + if ( + output.includes("Building app") || + output.includes("Compiling") || + output.includes("Installing package") || + output.includes("Bundling") + ) { + buildStarted = true; + } + }); + + child.stderr.on("data", (data) => { + const output = data.toString(); + // Check for build progress in stderr (Tauri outputs to stderr) + if ( + output.includes("Building app") || + output.includes("Compiling") || + output.includes("Installing package") || + output.includes("Bundling") || + output.includes("Finished") || + output.includes("Built application at:") + ) { + buildStarted = true; + } + // Only log actual errors, ignore build progress and warnings + if ( + !output.includes("warning:") && + !output.includes("verbose") && + !output.includes("npm info") && + !output.includes("Installing package") && + !output.includes("Package installed") && + !output.includes("Building app") && + !output.includes("Compiling") && + !output.includes("Finished") && + !output.includes("Built application at:") && + !output.includes("Bundling") && + !output.includes("npm http") && + output.trim().length > 0 + ) { + console.log(`Build error: ${output}`); + } + }); + + // Kill process after 30 seconds if build started successfully + const timeout = setTimeout(() => { + child.kill("SIGTERM"); + + // Register cleanup files (app bundle only for E2E tests) + const appFile = path.join(config.PROJECT_ROOT, `${testName}.app`); + runner.registerFile(appFile); + + if (buildStarted) { + resolve(true); + } else { + reject(new Error("Build did not start within timeout")); + } + }, 30000); + + child.on("close", (code) => { + clearTimeout(timeout); + const appFile = path.join(config.PROJECT_ROOT, `${testName}.app`); + runner.registerFile(appFile); + + if (buildStarted) { + resolve(true); + } else { + reject(new Error("Build process ended before starting")); + } + }); + + child.on("error", (error) => { + reject(new Error(`Process error: ${error.message}`)); + }); + + child.stdin.end(); + }); + }, + 35000, // 35 seconds timeout +); + +runner.addTest( + "Config File System Verification", + async () => { + const testName = "E2ETestConfig"; + const pakeDir = path.join(config.PROJECT_ROOT, "src-tauri", ".pake"); + + return new Promise((resolve, reject) => { + const command = `node "${config.CLI_PATH}" "${TEST_URLS.VALID}" --name "${testName}" --debug`; + + const child = spawn(command, { + shell: true, + cwd: config.PROJECT_ROOT, + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + PAKE_E2E_TEST: "1", + PAKE_CREATE_APP: "1", + }, + }); + + const checkConfigFiles = () => { + if (fs.existsSync(pakeDir)) { + // Verify config files exist in .pake directory + const configFile = path.join(pakeDir, "tauri.conf.json"); + const pakeConfigFile = path.join(pakeDir, "pake.json"); + + if (fs.existsSync(configFile) && fs.existsSync(pakeConfigFile)) { + // Verify config contains our test app name + try { + const config = JSON.parse(fs.readFileSync(configFile, "utf8")); + if (config.productName === testName) { + child.kill("SIGTERM"); + + // Register cleanup + runner.registerDirectory(pakeDir); + resolve(true); + return true; + } + } catch (error) { + // Continue if config parsing fails + } + } + } + return false; + }; + + child.stdout.on("data", (data) => { + const output = data.toString(); + + // Check if .pake directory is created + if ( + output.includes("Installing package") || + output.includes("Building app") + ) { + checkConfigFiles(); + } + }); + + child.stderr.on("data", (data) => { + const output = data.toString(); + + // Check stderr for build progress and config creation + if ( + output.includes("Installing package") || + output.includes("Building app") || + output.includes("Package installed") + ) { + checkConfigFiles(); + } + }); + + // Timeout after 15 seconds + setTimeout(() => { + child.kill("SIGTERM"); + runner.registerDirectory(pakeDir); + reject(new Error("Config verification timeout")); + }, 15000); + + child.on("error", (error) => { + reject(new Error(`Process error: ${error.message}`)); + }); + + child.stdin.end(); + }); + }, + 20000, +); + +export default runner; + +// Run if called directly +if (import.meta.url === `file://${process.argv[1]}`) { + runner.runAll().catch(console.error); +} diff --git a/tests/index.js b/tests/index.js index 14ee778..e7e4fe8 100644 --- a/tests/index.js +++ b/tests/index.js @@ -4,12 +4,16 @@ * Main Test Runner for Pake CLI * * This is the entry point for running all tests. - * Usage: node tests/index.js [--unit] [--integration] [--manual] + * Usage: node tests/index.js [--unit] [--integration] [--builder] [--e2e] [--full] + * + * By default, runs all tests including E2E packaging tests. + * Use specific flags to run only certain test suites. */ import cliTestRunner from "./cli.test.js"; import integrationTestRunner from "./integration.test.js"; import builderTestRunner from "./builder.test.js"; +import e2eTestRunner from "./e2e.test.js"; import { execSync } from "child_process"; import path from "path"; import { fileURLToPath } from "url"; @@ -23,6 +27,8 @@ const args = process.argv.slice(2); const runUnit = args.length === 0 || args.includes("--unit"); const runIntegration = args.length === 0 || args.includes("--integration"); const runBuilder = args.length === 0 || args.includes("--builder"); +const runE2E = + args.length === 0 || args.includes("--e2e") || args.includes("--full"); async function runAllTests() { console.log("🚀 Pake CLI Test Suite"); @@ -55,6 +61,14 @@ async function runAllTests() { console.log(""); } + if (runE2E) { + console.log("🚀 Running End-to-End Tests...\n"); + await e2eTestRunner.runAll(); + totalPassed += e2eTestRunner.results.filter((r) => r.passed).length; + totalTests += e2eTestRunner.results.length; + console.log(""); + } + // Final summary console.log("🎯 Overall Test Summary"); console.log("=======================");