diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 533b074f..2411c04f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,35 +73,34 @@ jobs: - name: Lint Affected run: npx nx affected --target=lint --parallel=3 -# Commented out because its hanging -# test: -# name: Test Affected -# needs: setup -# runs-on: ubuntu-latest -# -# steps: -# - uses: actions/checkout@v4 -# with: -# # We need to fetch all branches and commits so that Nx affected has a base to compare against. -# fetch-depth: 0 -# # Derive appropriate SHAs for base and head for `nx affected` commands -# - uses: nrwl/nx-set-shas@v3 -# - uses: actions/setup-node@v4 -# with: -# node-version: ${{ env.NODE_VERSION }} -# cache: 'npm' -# -# - name: Cache NPM Dependencies -# uses: actions/cache@v4 -# with: -# path: | -# node_modules -# ~/.cache -# dist -# key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }} -# -# - name: Test Affected -# run: npx nx affected --target=test --parallel=3 + test: + name: Test Affected + needs: setup + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + # We need to fetch all branches and commits so that Nx affected has a base to compare against. + fetch-depth: 0 + # Derive appropriate SHAs for base and head for `nx affected` commands + - uses: nrwl/nx-set-shas@v3 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Cache NPM Dependencies + uses: actions/cache@v4 + with: + path: | + node_modules + ~/.cache + dist + key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }} + + - name: Test Affected + run: npx nx affected --target=test --parallel=3 build: name: Build Affected @@ -132,6 +131,33 @@ jobs: - name: Build Affected run: npx nx affected -t build build-storybook --parallel=3 + e2e: + name: E2E Affected + needs: [build] + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: nrwl/nx-set-shas@v3 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Cache NPM Dependencies + uses: actions/cache@v4 + with: + path: | + node_modules + ~/.cache + dist + key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }} + + - name: E2E Affected + run: npx nx affected --target=e2e --parallel=3 + # publish-storybook: # name: Publish Storybook # needs: [build, lint] @@ -161,7 +187,7 @@ jobs: deploy-front-end: name: Deploy Front-End - needs: [build, lint] + needs: [test, build, lint, e2e] runs-on: ubuntu-latest steps: @@ -203,7 +229,7 @@ jobs: deploy-server: name: Deploy Server - needs: [build, lint] + needs: [test, build, lint, e2e] runs-on: ubuntu-latest steps: @@ -239,33 +265,3 @@ jobs: - name: Deploy Prod Server if: ${{ env.is-main-branch == 'true' }} run: npx nx affected --target=deploy --exclude='*,!tag:backend' --configuration=production - -# release: -# name: Release Affected -# needs: [build, lint] -# runs-on: ubuntu-latest -# -# steps: -# - uses: actions/checkout@v4 -# with: -# # We need to fetch all branches and commits so that Nx affected has a base to compare against. -# fetch-depth: 0 -# # Derive appropriate SHAs for base and head for `nx affected` commands -# - uses: nrwl/nx-set-shas@v3 -# - uses: actions/setup-node@v4 -# with: -# node-version: 20 -# cache: 'npm' -# -# - name: Cache NPM Dependencies -# uses: actions/cache@v4 -# with: -# path: | -# node_modules -# ~/.cache -# dist -# key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }} -# -# - name: Release Affected -## if: ${{ env.is-main-branch == 'true' }} -# run: npx nx affected -t nx-release-publish --parallel=3 diff --git a/.gitignore b/.gitignore index d0b8d1df..1a1d4566 100644 --- a/.gitignore +++ b/.gitignore @@ -115,3 +115,6 @@ dist # Nx Cache .nx + +# Temporaty files +tmp diff --git a/global-setup.e2e.ts b/global-setup.e2e.ts new file mode 100644 index 00000000..8a7e7c0c --- /dev/null +++ b/global-setup.e2e.ts @@ -0,0 +1,13 @@ +import { execSync } from 'child_process'; +import startLocalRegistry from './tools/scripts/start-local-registry'; +import stopLocalRegistry from './tools/scripts/stop-local-registry'; + +export async function setup() { + await startLocalRegistry(); + execSync('npm install -D @app-speed/esbuild-meta@e2e --force'); +} + +export async function teardown() { + stopLocalRegistry(); + execSync('npm uninstall @app-speed/esbuild-meta'); +} diff --git a/packages/esbuild-meta/.eslintrc.json b/packages/esbuild-meta/.eslintrc.json index 0dc93dd7..22d31df3 100644 --- a/packages/esbuild-meta/.eslintrc.json +++ b/packages/esbuild-meta/.eslintrc.json @@ -21,7 +21,11 @@ "@nx/dependency-checks": [ "error", { - "ignoredFiles": ["{projectRoot}/vite.config.{js,ts,mjs,mts}"] + "ignoredFiles": [ + "{projectRoot}/e2e/**/*", + "{projectRoot}/vitest.config.e2e.{js,ts,mjs,mts}", + "{projectRoot}/vite.config.{js,ts,mjs,mts}" + ] } ] } diff --git a/packages/esbuild-meta/e2e/__snapshots__/filter.test.e2e.ts.snap b/packages/esbuild-meta/e2e/__snapshots__/filter.test.e2e.ts.snap new file mode 100644 index 00000000..62535c40 --- /dev/null +++ b/packages/esbuild-meta/e2e/__snapshots__/filter.test.e2e.ts.snap @@ -0,0 +1,16 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`filter command > should have a help option 1`] = ` +"esbuild-meta filter + +Filters the meta file to only include chunks required by specified entry points + +Options: + -s, --statsPath The path to the stats.json file [string] [required] + -o, --outPath The path where the new file should be saved [string] [default: "initial-stats.json"] + --excludeDynamicImports, --eDI Should the dynamic imports be filtered out of the output chunk imports [boolean] [default: false] + -e, --entryPoints Entry points that should be considered for the bundle [array] [default: ["main-","polyfills-"]] + -v, --version Show version number [boolean] + -h, --help Show help [boolean] +" +`; diff --git a/packages/esbuild-meta/e2e/__snapshots__/root.test.e2e.ts.snap b/packages/esbuild-meta/e2e/__snapshots__/root.test.e2e.ts.snap new file mode 100644 index 00000000..27fa9512 --- /dev/null +++ b/packages/esbuild-meta/e2e/__snapshots__/root.test.e2e.ts.snap @@ -0,0 +1,13 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`--help > should show help 1`] = ` +"esbuild-meta [command] + +Commands: + esbuild-meta filter Filters the meta file to only include chunks required by specified entry points [aliases: f] + +Options: + -v, --version Show version number [boolean] + -h, --help Show help [boolean] +" +`; diff --git a/packages/esbuild-meta/e2e/filter.test.e2e.ts b/packages/esbuild-meta/e2e/filter.test.e2e.ts new file mode 100644 index 00000000..1c705b05 --- /dev/null +++ b/packages/esbuild-meta/e2e/filter.test.e2e.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { cliProcess } from './utils.js'; +import { DEMAND_STATS_PATH } from '../src/lib/filter-meta.js'; +import { INVALID_FILE_PATH_ERROR_MSG } from '../src/lib/utils.js'; + +describe('filter command', () => { + it('should have a help option', async () => { + const { stdout, stderr, code } = await cliProcess('esbuild-meta filter --help'); + expect(stdout).toMatchSnapshot(); + expect(stderr).toBeFalsy(); + expect(code).toBe(0); + }); + + it('should demand stats path option', async () => { + const { stdout, stderr, code } = await cliProcess('esbuild-meta filter'); + expect(stderr).toContain(DEMAND_STATS_PATH); + expect(stdout).toBeFalsy(); + expect(code).toBe(1); + }); + + it('should throw if stats path does not point to a file', async () => { + const INVALID_STATS_FILE = 'invalid-path.json'; + const { stdout, stderr, code } = await cliProcess(`esbuild-meta filter --statsPath ${INVALID_STATS_FILE}`); + expect(stderr).toContain(INVALID_FILE_PATH_ERROR_MSG('invalid-path.json')); + expect(stdout).toBeFalsy(); + expect(code).toBe(1); + }); +}); diff --git a/packages/esbuild-meta/e2e/root.test.e2e.ts b/packages/esbuild-meta/e2e/root.test.e2e.ts new file mode 100644 index 00000000..e89bdb36 --- /dev/null +++ b/packages/esbuild-meta/e2e/root.test.e2e.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { version } from '../package.json'; + +import { cliProcess, CliProcessOutput } from './utils.js'; + +describe('--help', () => { + let output: CliProcessOutput; + + beforeAll(async () => { + output = await cliProcess('esbuild-meta --help'); + }); + + it('should show help', async () => { + const { stdout, stderr, code } = output; + expect(stdout).toMatchSnapshot(); + expect(stderr).toBeFalsy(); + expect(code).toBe(0); + }); + + it('should alias to -h', async () => { + expect(await cliProcess('esbuild-meta --help')).toEqual(output); + }); +}); + +describe('--version', () => { + it('should show version', async () => { + const {stdout, stderr, code} = await cliProcess('esbuild-meta --version'); + expect(stdout).toContain(version); + expect(stderr).toBeFalsy(); + expect(code).toBe(0); + }); +}); diff --git a/packages/esbuild-meta/e2e/utils.ts b/packages/esbuild-meta/e2e/utils.ts new file mode 100644 index 00000000..c5f356c0 --- /dev/null +++ b/packages/esbuild-meta/e2e/utils.ts @@ -0,0 +1,19 @@ +import { spawn } from 'node:child_process'; + +export type CliProcessOutput = { + stdout: string; + stderr: string; + code: number | null; +} + +export const cliProcess = (command: string) => { + return new Promise((resolve) => { + const process = spawn(command, [], { stdio: 'pipe', shell: true }); + + let stdout = ''; + let stderr = ''; + process.stdout.on('data', (data) => stdout += String(data)); + process.stderr.on('data', (data) => stderr += String(data)); + process.on('close', code => resolve({ stdout, stderr, code })); + }) +} diff --git a/packages/esbuild-meta/project.json b/packages/esbuild-meta/project.json index 95b9a77f..49a743d8 100644 --- a/packages/esbuild-meta/project.json +++ b/packages/esbuild-meta/project.json @@ -1,9 +1,9 @@ { "name": "esbuild-meta", "$schema": "../../node_modules/nx/schemas/project-schema.json", + "root": "packages/esbuild-meta", "sourceRoot": "packages/esbuild-meta/src", - "projectType": "library", - "tags": [], + "projectType": "app", "targets": { "build": { "executor": "@nx/esbuild:esbuild", @@ -17,16 +17,25 @@ "format": ["esm"], "minify": true, "platform": "node", - "bundle": true, - "thirdParty": true + "bundle": true } }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"] + }, "test": { "executor": "@nx/vite:test", "outputs": ["{options.reportsDirectory}"], "options": { "reportsDirectory": "../../coverage/packages/esbuild-meta" } + }, + "e2e": { + "executor": "@nx/vite:test", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}-e2e"], + "options": { "config": "packages/esbuild-meta/vitest.config.e2e.ts" }, + "dependsOn": ["build"] } } } diff --git a/packages/esbuild-meta/src/lib/filter-meta.ts b/packages/esbuild-meta/src/lib/filter-meta.ts index 0fe0eadb..6f914c3b 100644 --- a/packages/esbuild-meta/src/lib/filter-meta.ts +++ b/packages/esbuild-meta/src/lib/filter-meta.ts @@ -8,11 +8,13 @@ import { makeJson, } from './utils.js'; -const distPath = { - alias: 'd', +export const DEMAND_STATS_PATH = 'The path to a stats.json file is required'; + +const statsPath = { + alias: 's', type: 'string', - default: 'dist', - description: 'The path to the stats.json file' + description: 'The path to the stats.json file', + demandOption: DEMAND_STATS_PATH, } as const satisfies Options; const outPath = { @@ -37,7 +39,7 @@ const entryPoints = { description: 'Entry points that should be considered for the bundle', } as const satisfies Options; -const filterMetaOptions = { distPath, outPath, excludeDynamicImports, entryPoints }; +const filterMetaOptions = { statsPath, outPath, excludeDynamicImports, entryPoints }; type FilterMetaOptions = InferredOptionTypes; type FilterMetaCommandModule = CommandModule; @@ -47,7 +49,7 @@ const filterMetaBuilder: CommandBuilder = (argv: Arg } const filterMetaHandler: FilterMetaCommandModule['handler'] = (argv: FilterMetaOptions) => { - const meta = getJson([argv.distPath]); + const meta = getJson(argv.statsPath); const entryPoints = extractEntryPoints(meta, argv.entryPoints); filterMetaFromEntryPoints(meta, entryPoints); if (argv.excludeDynamicImports) { diff --git a/packages/esbuild-meta/src/lib/utils.ts b/packages/esbuild-meta/src/lib/utils.ts index fb502a3b..f1fa1b4e 100644 --- a/packages/esbuild-meta/src/lib/utils.ts +++ b/packages/esbuild-meta/src/lib/utils.ts @@ -1,9 +1,17 @@ import { readFileSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { join, normalize } from 'node:path'; import { Metafile } from 'esbuild'; -export function getJson(path: string[]) { - return JSON.parse(readFileSync(join(...path), {encoding: 'utf-8'})) as T; +export const INVALID_FILE_PATH_ERROR_MSG = (path: string) => `No file found at ${path}`; + +export function getJson(path: string) { + const normalizedPath = normalize(path); + try { + return JSON.parse(readFileSync(normalizedPath, {encoding: 'utf-8'})) as T; + } + catch (e) { + throw new Error(INVALID_FILE_PATH_ERROR_MSG(normalizedPath)); + } } export function makeJson(path: string, file: any) { diff --git a/packages/esbuild-meta/src/main.ts b/packages/esbuild-meta/src/main.ts index 415c99cc..d9dd4d73 100644 --- a/packages/esbuild-meta/src/main.ts +++ b/packages/esbuild-meta/src/main.ts @@ -4,7 +4,16 @@ import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import { argv } from 'node:process'; import { filterMetaCommand } from './lib/filter-meta.js'; +import { version } from '../package.json'; -yargs(hideBin(argv)).command('$0', 'Default Command is filter', filterMetaCommand).parse(); +yargs(hideBin(argv)) + .scriptName('esbuild-meta') + .version(version).alias('v', 'version') + .showHelpOnFail(true) + .command(filterMetaCommand) + .help() + .alias('h', 'help') + .wrap(null) + .parse(); console.log('Esbuild Meta completed successfully'); diff --git a/packages/esbuild-meta/tsconfig.json b/packages/esbuild-meta/tsconfig.json index 897f0b96..36d2a010 100644 --- a/packages/esbuild-meta/tsconfig.json +++ b/packages/esbuild-meta/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "module": "NodeNext", "moduleResolution": "NodeNext", + "resolveJsonModule": true, "esModuleInterop": true, "target": "ES2022", "forceConsistentCasingInFileNames": true, diff --git a/packages/esbuild-meta/tsconfig.spec.json b/packages/esbuild-meta/tsconfig.spec.json index 0edf3f2f..c872c81b 100644 --- a/packages/esbuild-meta/tsconfig.spec.json +++ b/packages/esbuild-meta/tsconfig.spec.json @@ -7,6 +7,8 @@ "include": [ "vite.config.ts", "vitest.config.ts", + "vitest.config.e2e.ts", + "e2e/**/*.test.e2e.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.test.tsx", diff --git a/packages/esbuild-meta/vite.config.ts b/packages/esbuild-meta/vite.config.ts index d6c5228c..007c41e4 100644 --- a/packages/esbuild-meta/vite.config.ts +++ b/packages/esbuild-meta/vite.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ environment: 'node', include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], reporters: ['default'], + passWithNoTests: true, coverage: { reportsDirectory: '../../coverage/packages/esbuild-meta', provider: 'v8' }, }, }); diff --git a/packages/esbuild-meta/vitest.config.e2e.ts b/packages/esbuild-meta/vitest.config.e2e.ts new file mode 100644 index 00000000..6cae177e --- /dev/null +++ b/packages/esbuild-meta/vitest.config.e2e.ts @@ -0,0 +1,22 @@ +/// +import { defineConfig } from 'vite'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + cacheDir: '../../node_modules/.vite/esbuild-meta/e2e', + + plugins: [nxViteTsPaths()], + + test: { + watch: false, + pool: 'threads', + poolOptions: { threads: { singleThread: true } }, + testTimeout: 140_000, + reporters: ['default'], + globals: true, + cache: { dir: '../../node_modules/.vitest' }, + environment: 'node', + include: ['e2e/**/*.test.e2e.ts'], + globalSetup: '../../global-setup.e2e.ts' + }, +}); diff --git a/project.json b/project.json index 084e3a89..7620b5a4 100644 --- a/project.json +++ b/project.json @@ -1,5 +1,5 @@ { - "name": "app-speed", + "name": "@app-speed/source", "$schema": "node_modules/nx/schemas/project-schema.json", "targets": { "local-registry": { diff --git a/tools/scripts/start-local-registry.ts b/tools/scripts/start-local-registry.ts new file mode 100644 index 00000000..0601c650 --- /dev/null +++ b/tools/scripts/start-local-registry.ts @@ -0,0 +1,103 @@ +/** + * This script starts a local registry for e2e testing purposes. + * It is meant to be called in jest's globalSetup. + */ +import { execSync, spawn } from 'node:child_process'; +import { releasePublish, releaseVersion } from 'nx/release'; + +export default async () => { + const localRegistryTarget = '@app-speed/source:local-registry'; + const storage = './tmp/local-registry/storage'; + + global.stopLocalRegistry = await startLocalRegistry({ + localRegistryTarget, + storage, + verbose: true, + }); + + await releaseVersion({ + specifier: '0.0.0-e2e', + stageChanges: false, + gitCommit: false, + gitTag: false, + generatorOptionsOverrides: { + skipLockFileUpdate: true, + }, + }); + + await releasePublish({ + projects: ['esbuild-meta'], + tag: 'e2e', + firstRelease: true, + }); +}; + +// soft copy from https://github.com/nrwl/nx/blob/16.9.x/packages/js/src/plugins/jest/start-local-registry.ts +// original function does not work, because it uses require.resolve('nx') and fork, +// and it does not work with vite +function startLocalRegistry({ localRegistryTarget, storage, verbose }: { + localRegistryTarget: string; + storage?: string; + verbose?: boolean; +}) { + if (!localRegistryTarget) { + throw new Error(`localRegistryTarget is required`); + } + return new Promise<() => void>((resolve, reject) => { + const childProcess = spawn( + 'npx', + [ + 'nx', + ...`run ${localRegistryTarget} --location none --clear true`.split(' '), + ...(storage ? [`--storage`, storage] : []), + ], + { stdio: 'pipe', shell: true }, + ); + + const listener = data => { + if (verbose) { + process.stdout.write(data); + } + if (data.toString().includes('http://localhost:')) { + const port = parseInt(data.toString().match(/localhost:(?\d+)/)?.groups?.port); + console.info('Local registry started on port ' + port); + + const registry = `http://localhost:${port}`; + process.env.npm_config_registry = registry; + execSync( + `npm config set //localhost:${port}/:_authToken "secretVerdaccioToken"`, + ); + + // yarnv1 + process.env.YARN_REGISTRY = registry; + // yarnv2 + process.env.YARN_NPM_REGISTRY_SERVER = registry; + process.env.YARN_UNSAFE_HTTP_WHITELIST = 'localhost'; + + console.info('Set npm and yarn config registry to ' + registry); + + resolve(() => { + childProcess.kill(); + execSync(`npm config delete //localhost:${port}/:_authToken`); + }); + childProcess?.stdout?.off('data', listener); + } + }; + childProcess?.stdout?.on('data', listener); + childProcess?.stderr?.on('data', data => { + process.stderr.write(data); + }); + childProcess.on('error', err => { + console.error('local registry error', err); + reject(err); + }); + childProcess.on('exit', code => { + console.info('local registry exit', code); + if (code !== 0) { + reject(code); + } else { + resolve(() => {}); + } + }); + }); +} diff --git a/tools/scripts/stop-local-registry.ts b/tools/scripts/stop-local-registry.ts new file mode 100644 index 00000000..31d5d347 --- /dev/null +++ b/tools/scripts/stop-local-registry.ts @@ -0,0 +1,10 @@ +/** + * This script stops the local registry for e2e testing purposes. + * It is meant to be called in jest's globalTeardown. + */ + +export default () => { + if (global.stopLocalRegistry) { + global.stopLocalRegistry(); + } +};