diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 7ca0e2e3..00000000 --- a/.eslintignore +++ /dev/null @@ -1,2 +0,0 @@ -dist/ -dist_electron/ \ No newline at end of file diff --git a/.github/workflows/checks.build.yaml b/.github/workflows/checks.build.yaml index 0df3d9d6..ec657dd1 100644 --- a/.github/workflows/checks.build.yaml +++ b/.github/workflows/checks.build.yaml @@ -29,8 +29,11 @@ jobs: name: Install dependencies run: npm ci - - name: Build + name: Build web run: npm run build -- --mode ${{ matrix.mode }} + - + name: Verify web build artifacts + run: npm run check:verify-build-artifacts -- --web build-desktop: strategy: @@ -54,11 +57,17 @@ jobs: name: Install dependencies run: npm ci - - name: Prebuild + name: Prebuild desktop run: npm run electron:prebuild -- --mode ${{ matrix.mode }} - - name: Build + name: Verify unbundled desktop build artifacts + run: npm run check:verify-build-artifacts -- --electron-unbundled + - + name: Build (bundle and package) desktop application run: npm run electron:build -- --publish never + - + name: Verify bundled desktop build artifacts + run: npm run check:verify-build-artifacts -- --electron-bundled create-icons: strategy: diff --git a/.github/workflows/release.site.yaml b/.github/workflows/release.site.yaml index f721d99d..def57733 100644 --- a/.github/workflows/release.site.yaml +++ b/.github/workflows/release.site.yaml @@ -94,11 +94,21 @@ jobs: name: "App: Build" run: npm run build working-directory: app + - + name: "App: Verify web build artifacts" + run: npm run check:verify-build-artifacts -- --web + working-directory: app - name: "App: Deploy to S3" + shell: bash run: >- + declare web_output_dir + if ! web_output_dir=$(cd app && node scripts/print-dist-dir.js --web); then + echo 'Error: Could not determine distribution directory.' + exit 1 + fi bash "aws/scripts/deploy/deploy-to-s3.sh" \ - --folder app/dist \ + --folder "${web_output_dir}" \ --web-stack-name privacysexy-web-stack --web-stack-s3-name-output-name S3BucketName \ --storage-class ONEZONE_IA \ --role-arn ${{secrets.AWS_S3_SITE_DEPLOYMENT_ROLE_ARN}} \ diff --git a/.gitignore b/.gitignore index 27f2cb26..4e8954d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,5 @@ node_modules -dist/ +/dist-*/ .vs .vscode/**/* -!.vscode/extensions.json -#Electron-builder output -/dist_electron +!.vscode/extensions.json \ No newline at end of file diff --git a/dist-dirs.json b/dist-dirs.json new file mode 100644 index 00000000..76beebac --- /dev/null +++ b/dist-dirs.json @@ -0,0 +1,5 @@ +{ + "electronUnbundled": "dist-electron-unbundled", + "electronBundled": "dist-electron-bundled", + "web": "dist-web" +} diff --git a/docs/development.md b/docs/development.md index 3fa43413..0bacd91c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -5,7 +5,9 @@ Before your commit, a good practice is to: 1. [Run unit tests](#testing) 2. [Lint your code](#linting) -You could run other types of tests as well, but they may take longer time and overkill for your changes. Automated actions executes the tests for a pull request or change in the main branch. See [ci-cd.md](./ci-cd.md) for more information. +You could run other types of tests as well, but they may take longer time and overkill for your changes. +Automated actions are set up to execute these tests as necessary. +See [ci-cd.md](./ci-cd.md) for more information. ## Commands @@ -65,13 +67,26 @@ You could run other types of tests as well, but they may take longer time and ov - Build desktop application: `npm run electron:build` - (Re)create icons (see [documentation](../img/README.md)): `npm run create-icons` -### Utility Scripts +### Scripts -- Run fresh NPM install: [`./scripts/fresh-npm-install.sh`](../scripts/fresh-npm-install.sh) +📖 For detailed options and behavior for any of the following scripts, please refer to the script file itself. + +#### Utility scripts + +- [**`./scripts/fresh-npm-install.sh`**](../scripts/fresh-npm-install.sh): + - Run fresh NPM install. - This script provides a clean NPM install, removing existing node modules and optionally the package-lock.json (when run with -n), then installs dependencies and runs unit tests. -- Configure VSCode: [`./scripts/configure-vscode.sh`](../scripts/configure-vscode.sh) +- [**`./scripts/configure-vscode.sh`**](../scripts/configure-vscode.sh): - This script checks and sets the necessary configurations for VSCode in `settings.json` file. +#### Automation scripts + +- [**`node scripts/print-dist-dir.js [-- ]`**](../scripts/print-dist-dir.js): + - Determines the absolute path of a distribution directory based on CLI arguments and outputs its absolute path. + - Primarily used by automation scripts. +- [**`npm run check:verify-build-artifacts [-- ]`**](../scripts/verify-build-artifacts.js): + - Verifies the existence and content of build artifacts. Useful for ensuring that the build process is generating the expected output. + ## Recommended extensions You should use EditorConfig to follow project style. diff --git a/electron-builder.cjs b/electron-builder.cjs new file mode 100644 index 00000000..8b8f4c11 --- /dev/null +++ b/electron-builder.cjs @@ -0,0 +1,43 @@ +/* eslint-disable no-template-curly-in-string */ + +const { join } = require('path'); +const { electronBundled, electronUnbundled } = require('./dist-dirs.json'); + +module.exports = { + // Common options + publish: { + provider: 'github', + vPrefixedTagName: false, // default: true + releaseType: 'release', // default: draft + }, + directories: { + output: electronBundled, + }, + extraMetadata: { + main: join(electronUnbundled, 'main/index.cjs'), // do not `path.resolve`, it expects a relative path + }, + + // Windows + win: { + target: 'nsis', + }, + nsis: { + artifactName: '${name}-Setup-${version}.${ext}', + }, + + // Linux + linux: { + target: 'AppImage', + }, + appImage: { + artifactName: '${name}-${version}.${ext}', + }, + + // macOS + mac: { + target: 'dmg', + }, + dmg: { + artifactName: '${name}-${version}.${ext}', + }, +}; diff --git a/electron-builder.yml b/electron-builder.yml deleted file mode 100644 index dac7d78d..00000000 --- a/electron-builder.yml +++ /dev/null @@ -1,31 +0,0 @@ -# ------- -# Windows -# ------- -win: - target: nsis -nsis: - artifactName: ${name}-Setup-${version}.${ext} - -# ----- -# Linux -# ----- -linux: - target: AppImage -appImage: - artifactName: ${name}-${version}.${ext} - -# ----- -# macOS -# ----- -mac: - target: dmg -dmg: - artifactName: ${name}-${version}.${ext} - -# ---------------- -# Publish options -# ---------------- -publish: - provider: 'github' - vPrefixedTagName: false # default: true - releaseType: release # default: draft diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 5f6e742f..93de7dcd 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -3,11 +3,12 @@ import { mergeConfig, UserConfig } from 'vite'; import { defineConfig, externalizeDepsPlugin } from 'electron-vite'; import { getAliasesFromTsConfig, getClientEnvironmentVariables } from './vite-config-helper'; import { createVueConfig } from './vite.config'; +import distDirs from './dist-dirs.json' assert { type: 'json' }; const MAIN_ENTRY_FILE = resolvePathFromProjectRoot('src/presentation/electron/main/index.ts'); const PRELOAD_ENTRY_FILE = resolvePathFromProjectRoot('src/presentation/electron/preload/index.ts'); const WEB_INDEX_HTML_PATH = resolvePathFromProjectRoot('src/presentation/index.html'); -const DIST_DIR = resolvePathFromProjectRoot('dist_electron/'); +const DIST_DIR = resolvePathFromProjectRoot(distDirs.electronUnbundled); export default defineConfig({ main: getSharedElectronConfig({ diff --git a/package.json b/package.json index d41a3e37..e0c85f3a 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy 🍑🍆", "author": "undergroundwires", "type": "module", + "main": "./dist-electron-unbundled/main/index.cjs", "scripts": { "dev": "vite", "build": "vue-tsc --noEmit && vite build", @@ -18,11 +19,12 @@ "icons:build": "node scripts/logo-update.js", "check:desktop": "vitest run --dir tests/checks/desktop-runtime-errors --environment node", "check:external-urls": "vitest run --dir tests/checks/external-urls --environment node", + "check:verify-build-artifacts": "node scripts/verify-build-artifacts", "electron:dev": "electron-vite dev", "electron:preview": "electron-vite preview", "electron:prebuild": "electron-vite build", "electron:build": "electron-builder", - "lint:eslint": "eslint .", + "lint:eslint": "eslint . --ignore-path .gitignore", "lint:md": "markdownlint **/*.md --ignore node_modules", "lint:md:consistency": "remark . --frail --use remark-preset-lint-consistent", "lint:md:relative-urls": "remark . --frail --use remark-validate-links", @@ -30,7 +32,6 @@ "postinstall": "electron-builder install-app-deps", "postuninstall": "electron-builder install-app-deps" }, - "main": "./dist_electron/main/index.cjs", "dependencies": { "@fortawesome/fontawesome-svg-core": "^6.4.0", "@fortawesome/free-brands-svg-icons": "^6.4.0", diff --git a/scripts/print-dist-dir.js b/scripts/print-dist-dir.js new file mode 100644 index 00000000..2f837945 --- /dev/null +++ b/scripts/print-dist-dir.js @@ -0,0 +1,58 @@ +/** + * Description: + * This script determines the absolute path of a distribution directory based on CLI arguments + * and outputs its absolute path. It is designed to be run programmatically by other scripts. + * + * Usage: + * node scripts/print-dist-dir.js [options] + * + * Options: + * --electron-unbundled Path for the unbundled Electron application + * --electron-bundled Path for the bundled Electron application + * --web Path for the web application + */ + +import { resolve } from 'path'; +import { readFile } from 'fs/promises'; + +const DIST_DIRS_JSON_FILE_PATH = resolve(process.cwd(), 'dist-dirs.json'); // cannot statically import because ESLint does not support it https://github.com/eslint/eslint/discussions/15305 +const CLI_ARGUMENTS = process.argv.slice(2); + +async function main() { + const distDirs = await readDistDirsJsonFile(DIST_DIRS_JSON_FILE_PATH); + const relativeDistDir = determineRelativeDistDir(distDirs, CLI_ARGUMENTS); + const absoluteDistDir = resolve(process.cwd(), relativeDistDir); + console.log(absoluteDistDir); +} + +function mapCliFlagsToDistDirs(distDirs) { + return { + '--electron-unbundled': distDirs.electronUnbundled, + '--electron-bundled': distDirs.electronBundled, + '--web': distDirs.web, + }; +} + +function determineRelativeDistDir(distDirsJsonObject, cliArguments) { + const cliFlagDistDirMap = mapCliFlagsToDistDirs(distDirsJsonObject); + const availableCliFlags = Object.keys(cliFlagDistDirMap); + const requestedCliFlags = cliArguments.filter((arg) => { + return availableCliFlags.includes(arg); + }); + if (!requestedCliFlags.length) { + throw new Error(`No distribution directory was requested. Please use one of these flags: ${availableCliFlags.join(', ')}`); + } + if (requestedCliFlags.length > 1) { + throw new Error(`Multiple distribution directories were requested, but this script only supports one: ${requestedCliFlags.join(', ')}`); + } + const selectedCliFlag = requestedCliFlags[0]; + return cliFlagDistDirMap[selectedCliFlag]; +} + +async function readDistDirsJsonFile(absoluteConfigJsonFilePath) { + const fileContentAsText = await readFile(absoluteConfigJsonFilePath, 'utf8'); + const parsedJsonData = JSON.parse(fileContentAsText); + return parsedJsonData; +} + +await main(); diff --git a/scripts/verify-build-artifacts.js b/scripts/verify-build-artifacts.js new file mode 100644 index 00000000..947476ca --- /dev/null +++ b/scripts/verify-build-artifacts.js @@ -0,0 +1,133 @@ +/** + * Description: + * This script verifies the existence and content of build artifacts based on the + * provided CLI flags. It exists with exit code `0` if all verifications pass, otherwise + * with exit code `1`. + * + * Usage: + * node scripts/verify-build-artifacts.js [options] + * + * Options: + * --electron-unbundled Verify artifacts for the unbundled Electron application. + * --electron-bundled Verify artifacts for the bundled Electron application. + * --web Verify artifacts for the web application. + */ + +import { access, readdir } from 'fs/promises'; +import { exec } from 'child_process'; +import { resolve } from 'path'; + +const PROCESS_ARGUMENTS = process.argv.slice(2); +const PRINT_DIST_DIR_SCRIPT_BASE_COMMAND = 'node scripts/print-dist-dir'; + +async function main() { + const buildConfigs = getBuildVerificationConfigs(); + if (!anyCommandsFound(Object.keys(buildConfigs))) { + die(`No valid command found in process arguments. Expected one of: ${Object.keys(buildConfigs).join(', ')}`); + } + /* eslint-disable no-await-in-loop */ + for (const [command, config] of Object.entries(buildConfigs)) { + if (PROCESS_ARGUMENTS.includes(command)) { + const distDir = await executePrintDistDirScript(config.printDistDirScriptArgument); + await verifyDirectoryExists(distDir); + await verifyNonEmptyDirectory(distDir); + await verifyFilesExist(distDir, config.filePatterns); + } + } + /* eslint-enable no-await-in-loop */ + console.log('✅ Build completed successfully and all expected artifacts are in place.'); + process.exit(0); +} + +function getBuildVerificationConfigs() { + return { + '--electron-unbundled': { + printDistDirScriptArgument: '--electron-unbundled', + filePatterns: [ + /main[/\\]index\.cjs/, + /preload[/\\]index\.cjs/, + /renderer[/\\]index\.htm(l)?/, + ], + }, + '--electron-bundled': { + printDistDirScriptArgument: '--electron-bundled', + filePatterns: [ + /latest.*\.yml/, // generates latest.yml for auto-updates + /.*-\d+\.\d+\.\d+\..*/, // a file with extension and semantic version (packaged application) + ], + }, + '--web': { + printDistDirScriptArgument: '--web', + filePatterns: [ + /index\.htm(l)?/, + ], + }, + }; +} + +function anyCommandsFound(commands) { + return PROCESS_ARGUMENTS.some((arg) => commands.includes(arg)); +} + +async function verifyDirectoryExists(directoryPath) { + try { + await access(directoryPath); + } catch (error) { + die(`Directory does not exist at \`${directoryPath}\`:\n\t${error.message}`); + } +} + +async function verifyNonEmptyDirectory(directoryPath) { + const files = await readdir(directoryPath); + if (files.length === 0) { + die(`Directory is empty at \`${directoryPath}\``); + } +} + +async function verifyFilesExist(directoryPath, filePatterns) { + const files = await listAllFilesRecursively(directoryPath); + for (const pattern of filePatterns) { + const match = files.some((file) => pattern.test(file)); + if (!match) { + die( + `No file matches the pattern ${pattern.source} in directory \`${directoryPath}\``, + `\nFiles in directory:\n${files.map((file) => `\t- ${file}`).join('\n')}`, + ); + } + } +} + +async function listAllFilesRecursively(directoryPath) { + const dir = await readdir(directoryPath, { withFileTypes: true }); + const files = await Promise.all(dir.map(async (dirent) => { + const absolutePath = resolve(directoryPath, dirent.name); + if (dirent.isDirectory()) { + return listAllFilesRecursively(absolutePath); + } + return absolutePath; + })); + return files.flat(); +} + +async function executePrintDistDirScript(flag) { + return new Promise((resolve, reject) => { + const commandToRun = `${PRINT_DIST_DIR_SCRIPT_BASE_COMMAND} ${flag}`; + + exec(commandToRun, (error, stdout, stderr) => { + if (error) { + reject(new Error(`Execution failed with error: ${error}`)); + } else if (stderr) { + reject(new Error(`Execution failed with stderr: ${stderr}`)); + } else { + resolve(stdout.trim()); + } + }); + }); +} + +function die(...message) { + console.error(...message); + process.exit(1); +} + +await main(); diff --git a/tests/checks/desktop-runtime-errors/check-desktop-runtime-errors/config.ts b/tests/checks/desktop-runtime-errors/check-desktop-runtime-errors/config.ts index 6e520b2f..ff437fda 100644 --- a/tests/checks/desktop-runtime-errors/check-desktop-runtime-errors/config.ts +++ b/tests/checks/desktop-runtime-errors/check-desktop-runtime-errors/config.ts @@ -1,7 +1,13 @@ import { join } from 'path'; +import distDirs from '@/../dist-dirs.json' assert { type: 'json' }; -export const DESKTOP_BUILD_COMMAND = 'npm run electron:prebuild && npm run electron:build -- --publish never'; +export const DESKTOP_BUILD_COMMAND = [ + 'npm run electron:prebuild', + 'npm run check:verify-build-artifacts -- --electron-unbundled', + 'npm run electron:build -- --publish never', + 'npm run check:verify-build-artifacts -- --electron-bundled', +].join(' && '); export const PROJECT_DIR = process.cwd(); -export const DESKTOP_DIST_PATH = join(PROJECT_DIR, 'dist'); +export const DESKTOP_DIST_PATH = join(PROJECT_DIR, distDirs.electronBundled); export const APP_EXECUTION_DURATION_IN_SECONDS = 60; // Long enough for CI runners export const SCREENSHOT_PATH = join(PROJECT_DIR, 'screenshot.png'); diff --git a/tests/checks/desktop-runtime-errors/check-desktop-runtime-errors/utils/npm.ts b/tests/checks/desktop-runtime-errors/check-desktop-runtime-errors/utils/npm.ts index 0ccec34d..391d67ff 100644 --- a/tests/checks/desktop-runtime-errors/check-desktop-runtime-errors/utils/npm.ts +++ b/tests/checks/desktop-runtime-errors/check-desktop-runtime-errors/utils/npm.ts @@ -60,7 +60,11 @@ export async function npmBuild( cwd: projectDir, }); if (error) { - log(error, LogLevel.Warn); // Cannot disable Vue CLI errors, stderr contains false-positives. + die(error); + } + + if (await isDirMissingOrEmpty(distDir)) { + die(`The desktop application build process did not produce the expected artifacts. The output directory "${distDir}" is empty or missing.`); } } diff --git a/tests/checks/desktop-runtime-errors/main.spec.ts b/tests/checks/desktop-runtime-errors/main.spec.ts index e7f006f7..e98e7d3c 100644 --- a/tests/checks/desktop-runtime-errors/main.spec.ts +++ b/tests/checks/desktop-runtime-errors/main.spec.ts @@ -1,23 +1,67 @@ -import { test } from 'vitest'; +import { + describe, it, beforeAll, afterAll, +} from 'vitest'; import { main } from './check-desktop-runtime-errors/main'; import { COMMAND_LINE_FLAGS, CommandLineFlag } from './check-desktop-runtime-errors/cli-args'; -test('should have no desktop runtime errors', async () => { - // arrange - setCommandLineFlagsFromEnvironmentVariables(); - let exitCode: number; - global.process.exit = (code?: number): never => { - exitCode = code; - return undefined as never; - }; - // act - await main(); - // assert - expect(exitCode).to.equal(0); -}, { - timeout: 60 /* minutes */ * 10000, +describe('desktop runtime error checks', () => { + const { waitForExitCode } = useInterceptedProcessExitOrCompletion(beforeAll, afterAll); + it('should successfully execute the main function and exit with a zero status code', async () => { + // arrange + setCommandLineFlagsFromEnvironmentVariables(); + // act + const exitCode = await waitForExitCode( + () => main(), + ); + // assert + expect(exitCode).to.equal(0); + }, { + timeout: 60 /* minutes */ * 60000, + }); }); +function useInterceptedProcessExitOrCompletion( + beforeTest: (callback: () => void) => void, + afterTest: (callback: () => void) => void, +) { + const originalFunction = global.process.exit; + let isExitCodeReceived = false; + let exitCodeResolver: (value: number | undefined) => void; + const waitForExitCode = (runner: () => Promise) => new Promise( + (resolve, reject) => { + exitCodeResolver = resolve; + runner() + .catch((error) => { + if (isExitCodeReceived) { + return; + } + console.error('Process did not call `process.exit` but threw an error:', error); + reject(error); + }) + .then(() => { + if (isExitCodeReceived) { + return; + } + console.log('Process completed without calling `process.exit`. Treating as `0` exit code.'); + exitCodeResolver(0); + }); + }, + ); + beforeTest(() => { + global.process.exit = (code?: number): never => { + exitCodeResolver(code); + isExitCodeReceived = true; + return undefined as never; + }; + }); + afterTest(() => { + global.process.exit = originalFunction; + }); + return { + waitForExitCode, + }; +} + /* Map environment variables to CLI arguments for compatibility with Vitest. */ diff --git a/vite.config.ts b/vite.config.ts index dbc6044a..c077e180 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,7 @@ import { defineConfig, UserConfig } from 'vite'; import legacy from '@vitejs/plugin-legacy'; import vue from '@vitejs/plugin-vue2'; import ViteYaml from '@modyfi/vite-plugin-yaml'; +import distDirs from './dist-dirs.json' assert { type: 'json' }; import { getAliasesFromTsConfig, getClientEnvironmentVariables, getSelfDirectoryAbsolutePath } from './vite-config-helper'; const WEB_DIRECTORY = resolve(getSelfDirectoryAbsolutePath(), 'src/presentation'); @@ -14,6 +15,9 @@ export function createVueConfig(options?: { }): UserConfig { return { root: WEB_DIRECTORY, + build: { + outDir: resolve(getSelfDirectoryAbsolutePath(), distDirs.web), + }, plugins: [ vue(), ViteYaml(),