Compare commits
19 Commits
exit-handler
...
t
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a872f733e | |||
| 1dd8ce8995 | |||
| 9cdd5f03f4 | |||
| de0f24a5b0 | |||
| ba22eceb31 | |||
| eceed8b669 | |||
| aa2a80d7b9 | |||
| 81283b17d9 | |||
| 0b24a405ed | |||
| 86c59654b9 | |||
| f311341258 | |||
| 3664c2bbb2 | |||
| 1dfc8cbbb3 | |||
| 62dba7523d | |||
| 1e6e8ad952 | |||
| d82e7ef41a | |||
| 0d9f4d24dc | |||
| 38793d71f4 | |||
| 023d4f33e7 |
@@ -25,6 +25,8 @@ jobs:
|
||||
NX_CLOUD_DTE_V2: 'true'
|
||||
NX_CLOUD_DTE_SUMMARY: 'true'
|
||||
NX_CLOUD_NO_TIMEOUTS: 'true'
|
||||
# NX_BRANCH: 'jhjhjh'
|
||||
GIT_REPOSITORY_URL: 'https://github.com/JamesHenry/nx.git'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -73,19 +75,7 @@ jobs:
|
||||
run: |
|
||||
pids=()
|
||||
|
||||
pnpm nx-cloud record -- nx format:check --base=$NX_BASE --head=$NX_HEAD &
|
||||
pids+=($!)
|
||||
|
||||
pnpm nx-cloud record -- nx sync:check
|
||||
pids+=($!)
|
||||
|
||||
pnpm nx-cloud record -- nx-cloud conformance:check
|
||||
pids+=($!)
|
||||
|
||||
pnpm nx run-many -t check-imports check-commit check-lock-files check-codeowners --parallel=1 --no-dte &
|
||||
pids+=($!)
|
||||
|
||||
pnpm nx affected --targets=lint,test,build,e2e,e2e-ci --base=$NX_BASE --head=$NX_HEAD --parallel=3 &
|
||||
pnpm nx e2e-ci e2e-nx-tui
|
||||
pids+=($!)
|
||||
|
||||
for pid in "${pids[@]}"; do
|
||||
@@ -157,4 +147,4 @@ jobs:
|
||||
pnpm nx affected -t e2e-macos-local --parallel=1 --base=$NX_BASE --head=$NX_HEAD
|
||||
else
|
||||
echo "Skip E2E tests for macOS as there are no changes in React Native projects."
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
# we should generate an applicable version number within publish-resolve-data.js and use a custom ref of the PR branch name.
|
||||
resolve-required-data:
|
||||
name: Resolve Required Data
|
||||
if: ${{ github.repository_owner == 'nrwl' }}
|
||||
if: ${{ github.repository_owner == 'JamesHenry' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.script.outputs.version }}
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Check out the latest master branch to get its copy of nx-release.ts
|
||||
repository: nrwl/nx
|
||||
repository: JamesHenry/nx
|
||||
ref: master
|
||||
path: latest-master-checkout
|
||||
|
||||
@@ -108,15 +108,15 @@ jobs:
|
||||
FILE_TO_COMPARE: "scripts/nx-release.ts"
|
||||
run: |
|
||||
if ! cmp -s "latest-master-checkout/${{ env.FILE_TO_COMPARE }}" "pr-branch-checkout/${{ env.FILE_TO_COMPARE }}"; then
|
||||
echo "🛑 Error: The file ${{ env.FILE_TO_COMPARE }} is different on the ${{ steps.script.outputs.ref }} branch on ${{ steps.script.outputs.repo }} vs latest master on nrwl/nx, cancelling workflow. If you did not modify the file, then you likely just need to rebase/merge latest master."
|
||||
echo "🛑 Error: The file ${{ env.FILE_TO_COMPARE }} is different on the ${{ steps.script.outputs.ref }} branch on ${{ steps.script.outputs.repo }} vs latest master on JamesHenry/nx, cancelling workflow. If you did not modify the file, then you likely just need to rebase/merge latest master."
|
||||
exit 1
|
||||
else
|
||||
echo "✅ The file ${{ env.FILE_TO_COMPARE }} is identical between the ${{ steps.script.outputs.ref }} branch on ${{ steps.script.outputs.repo }} and latest master on nrwl/nx."
|
||||
echo "✅ The file ${{ env.FILE_TO_COMPARE }} is identical between the ${{ steps.script.outputs.ref }} branch on ${{ steps.script.outputs.repo }} and latest master on JamesHenry/nx."
|
||||
fi
|
||||
|
||||
build:
|
||||
needs: [ resolve-required-data ]
|
||||
if: ${{ github.repository_owner == 'nrwl' }}
|
||||
if: ${{ github.repository_owner == 'JamesHenry' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -288,7 +288,7 @@ jobs:
|
||||
|
||||
build-freebsd:
|
||||
needs: [ resolve-required-data ]
|
||||
if: ${{ github.repository_owner == 'nrwl' }}
|
||||
if: ${{ github.repository_owner == 'JamesHenry' }}
|
||||
runs-on: ubuntu-latest
|
||||
name: Build FreeBSD
|
||||
timeout-minutes: 45
|
||||
@@ -351,7 +351,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
if: ${{ github.repository_owner == 'nrwl' }}
|
||||
if: ${{ github.repository_owner == 'JamesHenry' }}
|
||||
name: Publish
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -365,7 +365,7 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
NPM_CONFIG_PROVENANCE: false
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -396,11 +396,11 @@ jobs:
|
||||
- name: List artifacts
|
||||
run: ls -R artifacts
|
||||
shell: bash
|
||||
- name: Build Wasm
|
||||
run: |
|
||||
wget https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-23/wasi-sdk-23.0-x86_64-linux.tar.gz
|
||||
tar -xvf wasi-sdk-23.0-x86_64-linux.tar.gz
|
||||
pnpm build:wasm
|
||||
# - name: Build Wasm
|
||||
# run: |
|
||||
# wget https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-23/wasi-sdk-23.0-x86_64-linux.tar.gz
|
||||
# tar -xvf wasi-sdk-23.0-x86_64-linux.tar.gz
|
||||
# pnpm build:wasm
|
||||
- name: Publish
|
||||
env:
|
||||
VERSION: ${{ needs.resolve-required-data.outputs.version }}
|
||||
@@ -438,8 +438,8 @@ jobs:
|
||||
});
|
||||
|
||||
pr_failure_comment:
|
||||
# Run this job if it is a PR release, running on the nrwl origin, and any of the required jobs failed
|
||||
if: ${{ github.repository_owner == 'nrwl' && github.event.inputs.pr && always() && contains(needs.*.result, 'failure') }}
|
||||
# Run this job if it is a PR release, running on the JamesHenry origin, and any of the required jobs failed
|
||||
if: ${{ github.repository_owner == 'JamesHenry' && github.event.inputs.pr && always() && contains(needs.*.result, 'failure') }}
|
||||
needs: [ resolve-required-data, build, build-freebsd, publish ]
|
||||
name: (PR Release Failure Only) Create comment for failed PR release
|
||||
runs-on: ubuntu-latest
|
||||
@@ -453,7 +453,7 @@ jobs:
|
||||
script: |
|
||||
const message = `
|
||||
Failed to publish a PR release of this pull request, triggered by @${{ github.triggering_actor }}.
|
||||
See the failed workflow run at: https://github.com/nrwl/nx/actions/runs/${{ github.run_id }}
|
||||
See the failed workflow run at: https://github.com/JamesHenry/nx/actions/runs/${{ github.run_id }}
|
||||
`;
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
|
||||
@@ -15,6 +15,7 @@ Target's configuration
|
||||
- [cache](../../devkit/documents/TargetConfiguration#cache): boolean
|
||||
- [command](../../devkit/documents/TargetConfiguration#command): string
|
||||
- [configurations](../../devkit/documents/TargetConfiguration#configurations): Object
|
||||
- [continuous](../../devkit/documents/TargetConfiguration#continuous): boolean
|
||||
- [defaultConfiguration](../../devkit/documents/TargetConfiguration#defaultconfiguration): string
|
||||
- [dependsOn](../../devkit/documents/TargetConfiguration#dependson): (string | TargetDependencyConfig)[]
|
||||
- [executor](../../devkit/documents/TargetConfiguration#executor): string
|
||||
@@ -55,6 +56,14 @@ Sets of options
|
||||
|
||||
---
|
||||
|
||||
### continuous
|
||||
|
||||
• `Optional` **continuous**: `boolean`
|
||||
|
||||
Whether this target runs continuously
|
||||
|
||||
---
|
||||
|
||||
### defaultConfiguration
|
||||
|
||||
• `Optional` **defaultConfiguration**: `string`
|
||||
|
||||
@@ -7,6 +7,7 @@ A representation of the invocation of an Executor
|
||||
### Properties
|
||||
|
||||
- [cache](../../devkit/documents/Task#cache): boolean
|
||||
- [continuous](../../devkit/documents/Task#continuous): boolean
|
||||
- [endTime](../../devkit/documents/Task#endtime): number
|
||||
- [hash](../../devkit/documents/Task#hash): string
|
||||
- [hashDetails](../../devkit/documents/Task#hashdetails): Object
|
||||
@@ -28,6 +29,14 @@ Determines if a given task should be cacheable.
|
||||
|
||||
---
|
||||
|
||||
### continuous
|
||||
|
||||
• `Optional` **continuous**: `boolean`
|
||||
|
||||
This denotes if the task runs continuously
|
||||
|
||||
---
|
||||
|
||||
### endTime
|
||||
|
||||
• `Optional` **endTime**: `number`
|
||||
|
||||
@@ -6,12 +6,19 @@ Graph of Tasks to be executed
|
||||
|
||||
### Properties
|
||||
|
||||
- [continuousDependencies](../../devkit/documents/TaskGraph#continuousdependencies): Record<string, string[]>
|
||||
- [dependencies](../../devkit/documents/TaskGraph#dependencies): Record<string, string[]>
|
||||
- [roots](../../devkit/documents/TaskGraph#roots): string[]
|
||||
- [tasks](../../devkit/documents/TaskGraph#tasks): Record<string, Task>
|
||||
|
||||
## Properties
|
||||
|
||||
### continuousDependencies
|
||||
|
||||
• **continuousDependencies**: `Record`\<`string`, `string`[]\>
|
||||
|
||||
---
|
||||
|
||||
### dependencies
|
||||
|
||||
• **dependencies**: `Record`\<`string`, `string`[]\>
|
||||
|
||||
@@ -7,6 +7,8 @@ The result of a completed [Task](../../devkit/documents/Task)
|
||||
### Properties
|
||||
|
||||
- [code](../../devkit/documents/TaskResult#code): number
|
||||
- [endTime](../../devkit/documents/TaskResult#endtime): number
|
||||
- [startTime](../../devkit/documents/TaskResult#starttime): number
|
||||
- [status](../../devkit/documents/TaskResult#status): TaskStatus
|
||||
- [task](../../devkit/documents/TaskResult#task): Task
|
||||
- [terminalOutput](../../devkit/documents/TaskResult#terminaloutput): string
|
||||
@@ -19,6 +21,18 @@ The result of a completed [Task](../../devkit/documents/Task)
|
||||
|
||||
---
|
||||
|
||||
### endTime
|
||||
|
||||
• `Optional` **endTime**: `number`
|
||||
|
||||
---
|
||||
|
||||
### startTime
|
||||
|
||||
• `Optional` **startTime**: `number`
|
||||
|
||||
---
|
||||
|
||||
### status
|
||||
|
||||
• **status**: `TaskStatus`
|
||||
|
||||
@@ -193,7 +193,9 @@ describe('Node Applications + webpack', () => {
|
||||
return config;
|
||||
});
|
||||
|
||||
runCLI(`serve ${nodeApp1} --watch=false`);
|
||||
await runCommandUntil(`serve ${nodeApp1} `, (output) =>
|
||||
output.includes('Hello World')
|
||||
);
|
||||
|
||||
checkFilesExist(`dist/apps/${nodeApp1}/main.js`);
|
||||
checkFilesExist(`dist/apps/${nodeApp2}/main.js`);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": ["plugin:playwright/recommended", "../../.eslintrc.json"],
|
||||
"ignorePatterns": ["!**/*"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["src/**/*.{ts,js,tsx,jsx}"],
|
||||
"rules": {
|
||||
"playwright/expect-expect": [
|
||||
"warn",
|
||||
{
|
||||
"assertFunctionNames": ["captureSnapshotWithMasking"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import isCI = require('is-ci');
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: './src/global-setup.ts',
|
||||
globalTeardown: './src/global-teardown.ts',
|
||||
testDir: './src',
|
||||
outputDir: '../../dist/.playwright/e2e-nx-tui/test-output',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'list',
|
||||
// how long the entire suite can run, prevent CI from timing out
|
||||
globalTimeout: process.env.CI ? 1_800_000 : undefined,
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
// how long each page.goto can take before timing out
|
||||
navigationTimeout: process.env.CI ? 30_000 : undefined,
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
// Configure viewport for consistent snapshots
|
||||
viewport: { width: 1200, height: 800 },
|
||||
},
|
||||
// Store snapshots next to test files
|
||||
snapshotPathTemplate: '{testDir}/{testFileDir}/__snapshots__/{arg}{ext}',
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "e2e-nx-tui",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "e2e/nx-tui",
|
||||
"projectType": "application",
|
||||
"implicitDependencies": ["nx"],
|
||||
"// targets": "to see all targets run: nx show project e2e-nx-tui --web"
|
||||
}
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,741 @@
|
||||
import { workspaceRoot } from '@nx/devkit';
|
||||
import type {
|
||||
Page,
|
||||
PageAssertionsToHaveScreenshotOptions,
|
||||
} from '@playwright/test';
|
||||
import { test as base } from '@playwright/test';
|
||||
import { spawn } from 'node-pty';
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// Re-export expect so that we are consistently importing from this file as the source of truth
|
||||
export { expect } from '@playwright/test';
|
||||
|
||||
interface CustomWindow {
|
||||
writeToTerminal: (data: string) => void;
|
||||
getTerminalState: () => string;
|
||||
resizeTerminal: (cols: number, rows: number) => void;
|
||||
fitTerminal: () => { cols: number; rows: number };
|
||||
}
|
||||
|
||||
// Define the browser context function type
|
||||
type BrowserFunction<T = void> = (
|
||||
this: Window & typeof globalThis & CustomWindow,
|
||||
...args: any[]
|
||||
) => T;
|
||||
|
||||
/**
|
||||
* Options for terminal masking when taking screenshots.
|
||||
*/
|
||||
interface MaskingOptions {
|
||||
// Pattern-based masks
|
||||
patterns?: (string | RegExp)[];
|
||||
// Coordinate-based masks
|
||||
regions?: Array<{
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}>;
|
||||
// Character to use for masking
|
||||
maskChar?: string;
|
||||
}
|
||||
|
||||
interface TerminalFixture {
|
||||
pty: any;
|
||||
dimensions: { cols: number; rows: number };
|
||||
sendInput: (input: string) => Promise<void>;
|
||||
waitForText: (
|
||||
text: string | RegExp,
|
||||
timeout?: number,
|
||||
pollInterval?: number
|
||||
) => Promise<boolean>;
|
||||
captureSnapshotWithMasking: (
|
||||
name: string,
|
||||
options: MaskingOptions | (string | RegExp)[],
|
||||
toHaveScreenshotOptions?: PageAssertionsToHaveScreenshotOptions,
|
||||
stabilityTimeout?: number,
|
||||
stabilityThreshold?: number
|
||||
) => Promise<void>;
|
||||
discoverMaskRegions: (
|
||||
patterns: (string | RegExp)[],
|
||||
stabilityTimeout?: number,
|
||||
stabilityThreshold?: number
|
||||
) => Promise<
|
||||
Array<{ left: number; top: number; width: number; height: number }>
|
||||
>;
|
||||
}
|
||||
|
||||
const HTML_DIR = join(workspaceRoot, 'dist/e2e/nx-tui');
|
||||
const HTML_PATH = join(HTML_DIR, 'terminal-renderer.html');
|
||||
|
||||
// Helper function to wait for the terminal content to stabilize
|
||||
async function waitForStability(
|
||||
page: Page,
|
||||
stabilityTimeout = 5000,
|
||||
stabilityThreshold = 5
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
let stableCount = 0;
|
||||
let lastContent = '';
|
||||
|
||||
while (
|
||||
stableCount < stabilityThreshold &&
|
||||
Date.now() - startTime < stabilityTimeout
|
||||
) {
|
||||
const getTerminalState: BrowserFunction<string> = function () {
|
||||
return this.getTerminalState();
|
||||
};
|
||||
const currentContent = await page.evaluate(getTerminalState);
|
||||
|
||||
if (currentContent === lastContent) {
|
||||
stableCount++;
|
||||
} else {
|
||||
stableCount = 0;
|
||||
lastContent = currentContent;
|
||||
}
|
||||
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
}
|
||||
|
||||
export const test = base.extend<{
|
||||
terminal: TerminalFixture;
|
||||
}>({
|
||||
terminal: async ({ page }, use) => {
|
||||
// Create and set up the HTML file
|
||||
createHtmlFile();
|
||||
await page.goto(`file://${HTML_PATH}`);
|
||||
|
||||
// Wait for terminal to initialize
|
||||
const checkFitTerminal: BrowserFunction<boolean> = function () {
|
||||
return !!this.fitTerminal?.();
|
||||
};
|
||||
await page.waitForFunction(checkFitTerminal);
|
||||
|
||||
// Get terminal dimensions
|
||||
const getFitDimensions: BrowserFunction<{ cols: number; rows: number }> =
|
||||
function () {
|
||||
return this.fitTerminal();
|
||||
};
|
||||
const dimensions = await page.evaluate(getFitDimensions);
|
||||
|
||||
// Create the terminal fixture
|
||||
const terminal: TerminalFixture = {
|
||||
dimensions,
|
||||
pty: null, // Will be set when spawning the process
|
||||
|
||||
// Helper function to send input to the terminal
|
||||
sendInput: async (input: string): Promise<void> => {
|
||||
console.log(`Sending input: ${JSON.stringify(input)}`);
|
||||
terminal.pty.write(input);
|
||||
await page.waitForTimeout(500);
|
||||
},
|
||||
|
||||
// Helper function to wait for text in the terminal
|
||||
waitForText: async (
|
||||
text: string | RegExp,
|
||||
timeout = 10000,
|
||||
pollInterval = 100
|
||||
): Promise<boolean> => {
|
||||
console.log(
|
||||
`Waiting for text: ${
|
||||
text instanceof RegExp ? text.toString() : `"${text}"`
|
||||
}`
|
||||
);
|
||||
|
||||
const checkText: BrowserFunction = function ({ searchText, isRegex }) {
|
||||
const content = this.getTerminalState();
|
||||
if (!isRegex) {
|
||||
return content.includes(searchText);
|
||||
} else {
|
||||
const regexParts = /\/(.*)\/([gimuy]*)/.exec(searchText);
|
||||
if (regexParts) {
|
||||
const [, pattern, flags] = regexParts;
|
||||
const regex = new RegExp(pattern, flags);
|
||||
return regex.test(content);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
await page.waitForFunction(
|
||||
checkText,
|
||||
{
|
||||
searchText: text instanceof RegExp ? text.toString() : text,
|
||||
isRegex: text instanceof RegExp,
|
||||
},
|
||||
{ timeout, polling: pollInterval }
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Captures a stable snapshot with precise masking of timing values
|
||||
*/
|
||||
captureSnapshotWithMasking: async (
|
||||
name: string,
|
||||
options: MaskingOptions | (string | RegExp)[],
|
||||
toHaveScreenshotOptions: PageAssertionsToHaveScreenshotOptions = {},
|
||||
stabilityTimeout = 5000,
|
||||
stabilityThreshold = 5
|
||||
): Promise<void> => {
|
||||
console.log(`Capturing masked snapshot: ${name}`);
|
||||
|
||||
// Handle backward compatibility - if options is an array, it's the old patterns format
|
||||
let maskingOptions: MaskingOptions;
|
||||
if (Array.isArray(options)) {
|
||||
maskingOptions = { patterns: options, maskChar: '█' };
|
||||
} else {
|
||||
maskingOptions = {
|
||||
patterns: options.patterns || [],
|
||||
regions: options.regions || [],
|
||||
maskChar: options.maskChar || '█',
|
||||
};
|
||||
}
|
||||
|
||||
// Wait for visual stability
|
||||
await waitForStability(page, stabilityTimeout, stabilityThreshold);
|
||||
|
||||
// Convert patterns to the format needed for the browser function
|
||||
const patterns = (maskingOptions.patterns || []).map((pattern) => ({
|
||||
pattern: pattern instanceof RegExp ? pattern.toString() : pattern,
|
||||
isRegex: pattern instanceof RegExp,
|
||||
}));
|
||||
|
||||
// Apply masking with either regions or patterns
|
||||
await page.evaluate(
|
||||
({ patternsList, regions, maskCharacter }) => {
|
||||
// Create a canvas overlay
|
||||
const terminal = document.querySelector('#terminal');
|
||||
if (!terminal) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const overlay = document.createElement('canvas');
|
||||
overlay.id = 'masking-canvas-overlay';
|
||||
overlay.style.position = 'absolute';
|
||||
overlay.style.top = '0';
|
||||
overlay.style.left = '0';
|
||||
overlay.style.width = '100%';
|
||||
overlay.style.height = '100%';
|
||||
overlay.style.pointerEvents = 'none';
|
||||
overlay.style.zIndex = '1000';
|
||||
|
||||
// Set canvas dimensions to match the terminal
|
||||
const terminalRect = terminal.getBoundingClientRect();
|
||||
overlay.width = terminalRect.width;
|
||||
overlay.height = terminalRect.height;
|
||||
|
||||
// Get the context for drawing
|
||||
const ctx = overlay.getContext('2d');
|
||||
if (!ctx) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Function to draw a mask rectangle
|
||||
function drawMask(x, y, width, height) {
|
||||
// Draw the background
|
||||
ctx.fillStyle = '#000'; // Terminal background color
|
||||
ctx.fillRect(x, y, width, height);
|
||||
|
||||
// Draw mask characters
|
||||
ctx.fillStyle = '#FFF'; // Text color
|
||||
ctx.font = '14px monospace'; // Adjust to match terminal font
|
||||
|
||||
// Determine how many mask characters to draw
|
||||
const charWidth = 8; // Approximate width of a monospace character
|
||||
const chars = Math.max(1, Math.floor(width / charWidth));
|
||||
|
||||
for (let i = 0; i < chars; i++) {
|
||||
ctx.fillText(
|
||||
maskCharacter,
|
||||
x + i * charWidth + charWidth / 2,
|
||||
y + height / 2
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Function to convert regex string to RegExp object
|
||||
function stringToRegex(pattern) {
|
||||
const regexParts = /\/(.*)\/([gimuy]*)/.exec(pattern);
|
||||
if (regexParts) {
|
||||
const [, regexPattern, flags] = regexParts;
|
||||
return new RegExp(regexPattern, flags);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Function to process a row's spans and build row data
|
||||
function buildRowData(spans) {
|
||||
const rowData = {
|
||||
text: '', // Concatenated text of all spans
|
||||
spanMap: [], // Maps each character position to its span and offset
|
||||
spans: [], // Information about each span
|
||||
};
|
||||
|
||||
// Collect data about each span
|
||||
spans.forEach((span) => {
|
||||
const text = span.textContent || '';
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
|
||||
const spanRect = span.getBoundingClientRect();
|
||||
const startIndex = rowData.text.length;
|
||||
|
||||
// Store span information
|
||||
rowData.spans.push({
|
||||
element: span,
|
||||
text: text,
|
||||
startIndex: startIndex,
|
||||
endIndex: startIndex + text.length - 1,
|
||||
rect: spanRect,
|
||||
charWidth: spanRect.width / text.length,
|
||||
});
|
||||
|
||||
// Map each character position to its span
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
rowData.spanMap.push({
|
||||
spanIndex: rowData.spans.length - 1,
|
||||
charIndex: i,
|
||||
});
|
||||
}
|
||||
|
||||
// Add this span's text to the row text
|
||||
rowData.text += text;
|
||||
});
|
||||
|
||||
return rowData;
|
||||
}
|
||||
|
||||
// Apply coordinate-based masking if regions are provided
|
||||
if (regions && regions.length > 0) {
|
||||
for (const region of regions) {
|
||||
drawMask(region.left, region.top, region.width, region.height);
|
||||
}
|
||||
}
|
||||
// Otherwise apply pattern-based masking
|
||||
else if (patternsList && patternsList.length > 0) {
|
||||
// Get terminal rows
|
||||
const rows = document.querySelectorAll('.xterm-rows > div');
|
||||
|
||||
// Process each row to find cross-span matches
|
||||
rows.forEach((row) => {
|
||||
// Get all spans in this row
|
||||
const spans = Array.from(row.querySelectorAll('span'));
|
||||
if (spans.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build row data
|
||||
const rowData = buildRowData(spans);
|
||||
|
||||
// Check for matches in the full row text
|
||||
patternsList.forEach(({ pattern, isRegex }) => {
|
||||
let regex;
|
||||
|
||||
if (isRegex) {
|
||||
regex = stringToRegex(pattern);
|
||||
} else {
|
||||
// Escape special characters for literal string search
|
||||
const escaped = pattern.replace(
|
||||
/[.*+?^${}()|[\]\\]/g,
|
||||
'\\$&'
|
||||
);
|
||||
regex = new RegExp(escaped, 'g');
|
||||
}
|
||||
|
||||
if (!regex) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find all matches in the full row text
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(rowData.text)) !== null) {
|
||||
const matchStart = match.index;
|
||||
const matchEnd = matchStart + match[0].length - 1;
|
||||
|
||||
// Draw mask for each character in the match, using the span mapping
|
||||
for (let pos = matchStart; pos <= matchEnd; pos++) {
|
||||
const mapping = rowData.spanMap[pos];
|
||||
if (!mapping) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const span = rowData.spans[mapping.spanIndex];
|
||||
const charX =
|
||||
span.rect.left + mapping.charIndex * span.charWidth;
|
||||
|
||||
// Draw mask for this character
|
||||
drawMask(
|
||||
charX - terminalRect.left,
|
||||
span.rect.top - terminalRect.top,
|
||||
span.charWidth,
|
||||
span.rect.height
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add the canvas overlay to the terminal
|
||||
terminal.appendChild(overlay);
|
||||
return true;
|
||||
},
|
||||
{
|
||||
patternsList: patterns,
|
||||
regions: maskingOptions.regions || [],
|
||||
maskCharacter: maskingOptions.maskChar,
|
||||
}
|
||||
);
|
||||
|
||||
// Capture and compare mac screenshots on a local run during development
|
||||
let screenshotName: string | ReadonlyArray<string> = `${name}.png`;
|
||||
if (process.env.CI !== 'true') {
|
||||
const os = require('os');
|
||||
if (os.platform() === 'darwin') {
|
||||
screenshotName = [`mac`, `${name}.png`];
|
||||
}
|
||||
}
|
||||
|
||||
// Take the screenshot with masking applied
|
||||
await test
|
||||
.expect(page)
|
||||
.toHaveScreenshot(screenshotName, toHaveScreenshotOptions);
|
||||
|
||||
// Remove the canvas overlay
|
||||
await page.evaluate(() => {
|
||||
const overlay = document.getElementById('masking-canvas-overlay');
|
||||
if (overlay) {
|
||||
overlay.remove();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Helper function to discover region coordinates to mask values when taking screenshots.
|
||||
* Returns coordinates of areas that match the specified patterns.
|
||||
*
|
||||
* Example usage:
|
||||
* const regions = await terminal.discoverMaskRegions([
|
||||
* /\d+(\.\d+)?ms/g, // Match milliseconds with possible decimals
|
||||
* /\d+(\.\d+)?s/g, // Match seconds with possible decimals
|
||||
* ]);
|
||||
*
|
||||
* console.log({ regions }); // These values can be plugged into `captureStableSnapshotWithMasking`
|
||||
*/
|
||||
discoverMaskRegions: async (
|
||||
patterns: (string | RegExp)[],
|
||||
stabilityTimeout = 5000,
|
||||
stabilityThreshold = 5
|
||||
): Promise<
|
||||
Array<{ left: number; top: number; width: number; height: number }>
|
||||
> => {
|
||||
console.log('Discovering mask regions...');
|
||||
|
||||
// Wait for visual stability
|
||||
await waitForStability(page, stabilityTimeout, stabilityThreshold);
|
||||
|
||||
// Convert patterns to the format needed for the browser function
|
||||
const formattedPatterns = patterns.map((pattern) => ({
|
||||
pattern: pattern instanceof RegExp ? pattern.toString() : pattern,
|
||||
isRegex: pattern instanceof RegExp,
|
||||
}));
|
||||
|
||||
// Discover regions that match patterns
|
||||
const regions = await page.evaluate((patternsList) => {
|
||||
// Function to convert regex string to RegExp object
|
||||
function stringToRegex(pattern) {
|
||||
const regexParts = /\/(.*)\/([gimuy]*)/.exec(pattern);
|
||||
if (regexParts) {
|
||||
const [, regexPattern, flags] = regexParts;
|
||||
return new RegExp(regexPattern, flags);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get terminal dimensions
|
||||
const terminal = document.querySelector('#terminal');
|
||||
if (!terminal) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const terminalRect = terminal.getBoundingClientRect();
|
||||
|
||||
// Get terminal rows
|
||||
const rows = document.querySelectorAll('.xterm-rows > div');
|
||||
|
||||
// Store all discovered regions
|
||||
const discoveredRegions = [];
|
||||
|
||||
// Process each row to find cross-span matches
|
||||
rows.forEach((row) => {
|
||||
// Get all spans in this row
|
||||
const spans = Array.from(row.querySelectorAll('span'));
|
||||
if (spans.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build row data with text and position information
|
||||
const rowData = {
|
||||
text: '', // Concatenated text of all spans
|
||||
spanMap: [], // Maps each character position to its span and offset
|
||||
spans: [], // Information about each span
|
||||
};
|
||||
|
||||
// Collect data about each span
|
||||
spans.forEach((span) => {
|
||||
const text = span.textContent || '';
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
|
||||
const spanRect = span.getBoundingClientRect();
|
||||
const startIndex = rowData.text.length;
|
||||
|
||||
// Store span information
|
||||
rowData.spans.push({
|
||||
element: span,
|
||||
text: text,
|
||||
startIndex: startIndex,
|
||||
endIndex: startIndex + text.length - 1,
|
||||
rect: spanRect,
|
||||
charWidth: spanRect.width / text.length,
|
||||
});
|
||||
|
||||
// Map each character position to its span
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
rowData.spanMap.push({
|
||||
spanIndex: rowData.spans.length - 1,
|
||||
charIndex: i,
|
||||
});
|
||||
}
|
||||
|
||||
// Add this span's text to the row text
|
||||
rowData.text += text;
|
||||
});
|
||||
|
||||
// Check for matches in the full row text
|
||||
patternsList.forEach(({ pattern, isRegex }) => {
|
||||
let regex: RegExp | null;
|
||||
|
||||
if (isRegex) {
|
||||
regex = stringToRegex(pattern);
|
||||
} else {
|
||||
// Escape special characters for literal string search
|
||||
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
regex = new RegExp(escaped, 'g');
|
||||
}
|
||||
|
||||
if (!regex) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find all matches in the full row text
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(rowData.text)) !== null) {
|
||||
const matchStart = match.index;
|
||||
const matchEnd = matchStart + match[0].length - 1;
|
||||
|
||||
// Calculate the bounding box for this match
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
// Find bounds for every character in the match
|
||||
for (let pos = matchStart; pos <= matchEnd; pos++) {
|
||||
const mapping = rowData.spanMap[pos];
|
||||
if (!mapping) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const span = rowData.spans[mapping.spanIndex];
|
||||
const charX =
|
||||
span.rect.left + mapping.charIndex * span.charWidth;
|
||||
|
||||
minX = Math.min(minX, charX);
|
||||
minY = Math.min(minY, span.rect.top);
|
||||
maxX = Math.max(maxX, charX + span.charWidth);
|
||||
maxY = Math.max(maxY, span.rect.top + span.rect.height);
|
||||
}
|
||||
|
||||
// Add the region if valid
|
||||
if (minX < Infinity) {
|
||||
discoveredRegions.push({
|
||||
left: Math.round(minX - terminalRect.left),
|
||||
top: Math.round(minY - terminalRect.top),
|
||||
width: Math.round(maxX - minX),
|
||||
height: Math.round(maxY - minY),
|
||||
text: match[0], // Include matched text for reference
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return discoveredRegions;
|
||||
}, formattedPatterns);
|
||||
|
||||
// Log discovered regions for easy copying
|
||||
console.log('Discovered mask regions:');
|
||||
console.log(JSON.stringify(regions, null, 2));
|
||||
|
||||
// Return the regions without the text property
|
||||
return regions.map(({ left, top, width, height }) => ({
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
// Use the fixture
|
||||
await use(terminal);
|
||||
|
||||
// Cleanup
|
||||
if (terminal.pty) {
|
||||
terminal.pty.kill();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Helper function to spawn a PTY process for the terminal fixture
|
||||
export const spawnCommand = async (
|
||||
terminal: TerminalFixture,
|
||||
page: Page,
|
||||
fullCommand: string,
|
||||
cwd?: string
|
||||
): Promise<void> => {
|
||||
const [cmd, ...args] = fullCommand.split(' ');
|
||||
|
||||
terminal.pty = spawn(cmd, args, {
|
||||
name: 'xterm-color',
|
||||
cols: terminal.dimensions.cols || 80,
|
||||
rows: terminal.dimensions.rows || 24,
|
||||
cwd: cwd || process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
// Enable the TUI via the environment variable
|
||||
// TODO: Re-evaluate this post release so that we run it in the tests exactly how users will
|
||||
NX_TUI: 'true',
|
||||
/**
|
||||
* Force color with a value of `2` means 256 colors which is a sane assumption for most terminals.
|
||||
* `3` would mean "true color" (~16 million colors) which is not supported by all terminals.
|
||||
*/
|
||||
FORCE_COLOR: '2',
|
||||
},
|
||||
});
|
||||
|
||||
terminal.pty.onData(async (data: string) => {
|
||||
// Send data to browser for rendering
|
||||
const writeToTerminal: BrowserFunction = function (text: string) {
|
||||
this.writeToTerminal(text);
|
||||
};
|
||||
await page.evaluate(writeToTerminal, data);
|
||||
});
|
||||
|
||||
return terminal.pty;
|
||||
};
|
||||
|
||||
// Create a simple HTML file with xterm.js for rendering the terminal
|
||||
function createHtmlFile(): void {
|
||||
// Use absolute paths for node_modules dependencies and ensure they exist
|
||||
const nodeModulesPath = join(workspaceRoot, 'node_modules');
|
||||
const requiredFiles = [
|
||||
join(nodeModulesPath, 'xterm/css/xterm.css'),
|
||||
join(nodeModulesPath, 'xterm/lib/xterm.js'),
|
||||
join(nodeModulesPath, 'xterm-addon-fit/lib/xterm-addon-fit.js'),
|
||||
join(nodeModulesPath, 'xterm-addon-serialize/lib/xterm-addon-serialize.js'),
|
||||
];
|
||||
for (const file of requiredFiles) {
|
||||
if (!existsSync(file)) {
|
||||
throw new Error(`Required file not found: ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="file://${join(
|
||||
nodeModulesPath,
|
||||
'xterm/css/xterm.css'
|
||||
)}" />
|
||||
<script src="file://${join(nodeModulesPath, 'xterm/lib/xterm.js')}"></script>
|
||||
<script src="file://${join(
|
||||
nodeModulesPath,
|
||||
'xterm-addon-fit/lib/xterm-addon-fit.js'
|
||||
)}"></script>
|
||||
<script src="file://${join(
|
||||
nodeModulesPath,
|
||||
'xterm-addon-serialize/lib/xterm-addon-serialize.js'
|
||||
)}"></script>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; height: 100%; width: 100%; background: #000; overflow: hidden; }
|
||||
#terminal { width: 100%; height: 100%; position: absolute; top: 0; left: 0; right: 0; bottom: 0; }
|
||||
.xterm-viewport { overflow-y: hidden !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="terminal"></div>
|
||||
<script>
|
||||
const term = new Terminal({
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
fontSize: 14,
|
||||
lineHeight: 1.2,
|
||||
cursorBlink: false,
|
||||
allowTransparency: true,
|
||||
theme: { background: '#000000', foreground: '#ffffff' },
|
||||
scrollback: 0,
|
||||
disableStdin: true
|
||||
});
|
||||
|
||||
term.open(document.getElementById('terminal'));
|
||||
|
||||
const fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
|
||||
const serializeAddon = new SerializeAddon.SerializeAddon();
|
||||
term.loadAddon(serializeAddon);
|
||||
|
||||
window.writeToTerminal = (data) => {
|
||||
term.write(data);
|
||||
};
|
||||
|
||||
window.getTerminalState = () => {
|
||||
return serializeAddon.serialize();
|
||||
};
|
||||
|
||||
window.resizeTerminal = (cols, rows) => {
|
||||
term.resize(cols, rows);
|
||||
fitAddon.fit();
|
||||
};
|
||||
|
||||
window.fitTerminal = () => {
|
||||
fitAddon.fit();
|
||||
return { cols: term.cols, rows: term.rows };
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
console.log('Terminal fitted to', term.cols, 'x', term.rows);
|
||||
}, 100);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
fitAddon.fit();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
if (!existsSync(HTML_DIR)) {
|
||||
mkdirSync(HTML_DIR, { recursive: true });
|
||||
}
|
||||
writeFileSync(HTML_PATH, html);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* We can reuse the jest-based setup for handling nx release and registry publishing
|
||||
* as-is because it doesn't actually have any jest specific code or logic.
|
||||
*/
|
||||
// eslint-disable-next-line @nx/enforce-module-boundaries
|
||||
import jestGlobalSetup from '../../utils/global-setup';
|
||||
|
||||
export default async function globalSetup() {
|
||||
await jestGlobalSetup({});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* We can reuse the jest-based teardown the local registry process and config
|
||||
* as-is because it doesn't actually have any jest specific code or logic.
|
||||
*/
|
||||
// eslint-disable-next-line @nx/enforce-module-boundaries
|
||||
import jestGlobalTeardown from '../../utils/global-teardown';
|
||||
|
||||
export default async function globalTeardown() {
|
||||
jestGlobalTeardown();
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
import {
|
||||
newProject,
|
||||
runCLI,
|
||||
tmpProjPath,
|
||||
updateFile,
|
||||
updateJson,
|
||||
} from '@nx/e2e/utils';
|
||||
import { spawnCommand, test } from './fixture';
|
||||
|
||||
const KEY_SEQUENCES = {
|
||||
DOWN_ARROW: '\x1B[B',
|
||||
UP_ARROW: '\x1B[A',
|
||||
TAB: '\t',
|
||||
SHIFT_TAB: '\x1B[Z',
|
||||
CTRL_Z: '\x1A',
|
||||
};
|
||||
|
||||
test.describe('Nx TUI', () => {
|
||||
test.beforeAll(async () => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
// Create an Nx workspace with two npm packages (that will have simple test scripts already)
|
||||
newProject({
|
||||
packages: ['@nx/js'],
|
||||
});
|
||||
runCLI(`generate @nx/workspace:npm-package my-pkg-1`);
|
||||
runCLI(`generate @nx/workspace:npm-package my-pkg-2`);
|
||||
|
||||
// Make pkg2 depend on pkg1 so that we have a deterministic order of task completions when combined with --parallel=1
|
||||
updateJson(`my-pkg-2/package.json`, (json) => {
|
||||
json.dependencies ??= {};
|
||||
json.dependencies['@proj/my-pkg-1'] = 'file:../my-pkg-1';
|
||||
return json;
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Task list navigation and terminal output pane toggling and pinning', () => {
|
||||
test('each task should be navigable with both the arrow keys and home row', async ({
|
||||
terminal,
|
||||
page,
|
||||
}) => {
|
||||
// Spawn the Nx TUI process
|
||||
await spawnCommand(
|
||||
terminal,
|
||||
page,
|
||||
'npx nx run-many --target=test --parallel=1',
|
||||
tmpProjPath()
|
||||
);
|
||||
|
||||
// Wait for the tasks to have completed
|
||||
await terminal.waitForText('Completed 2 tasks in', 15000);
|
||||
|
||||
/**
|
||||
* Hide non-deterministic timing values from the screenshots.
|
||||
* Using exact coordinates which are slightly larger than we expect the largest value to be
|
||||
* is the most robust way to avoid to do this in order to avoid issues with the difference
|
||||
* in length between task results in different environments e.g. 10ms locally and 125ms in CI.
|
||||
*/
|
||||
const regionsToMask = [
|
||||
{ left: 249, top: 57, width: 50, height: 19 },
|
||||
{ left: 1223, top: 95, width: 50, height: 19 },
|
||||
{ left: 1223, top: 114, width: 50, height: 19 },
|
||||
];
|
||||
|
||||
// Navigate down with arrow key
|
||||
await terminal.captureSnapshotWithMasking('1-before-down-arrow-nav', {
|
||||
regions: regionsToMask,
|
||||
});
|
||||
await terminal.sendInput(KEY_SEQUENCES.DOWN_ARROW);
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'2-after-down-arrow-before-up-arrow-nav',
|
||||
{
|
||||
regions: regionsToMask,
|
||||
}
|
||||
);
|
||||
|
||||
// Navigate up with arrow key
|
||||
await terminal.sendInput(KEY_SEQUENCES.UP_ARROW);
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'3-after-up-arrow-nav-before-down-j-nav',
|
||||
{
|
||||
regions: regionsToMask,
|
||||
}
|
||||
);
|
||||
|
||||
// Navigate down with j key
|
||||
await terminal.sendInput('j');
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'4-after-down-j-nav-before-up-k-nav',
|
||||
{
|
||||
regions: regionsToMask,
|
||||
}
|
||||
);
|
||||
|
||||
// Navigate up with k key
|
||||
await terminal.sendInput('k');
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'5-after-up-k-nav-before-down-j-nav',
|
||||
{
|
||||
regions: regionsToMask,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('pressing spacebar should toggle a single terminal output pane that dynamically changes its contents as the tasks list is navigated', async ({
|
||||
terminal,
|
||||
page,
|
||||
}) => {
|
||||
// Spawn the Nx TUI process
|
||||
await spawnCommand(
|
||||
terminal,
|
||||
page,
|
||||
'npx nx run-many --target=test --parallel=1',
|
||||
tmpProjPath()
|
||||
);
|
||||
|
||||
// Wait for the tasks to have completed
|
||||
await terminal.waitForText('Completed 2 tasks in', 15000);
|
||||
|
||||
const regionsToMask = [
|
||||
{ left: 249, top: 57, width: 50, height: 19 },
|
||||
{ left: 1223, top: 95, width: 50, height: 19 },
|
||||
{ left: 1223, top: 114, width: 50, height: 19 },
|
||||
];
|
||||
|
||||
// Toggle the terminal output pane open with the spacebar
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'spacebar-terminal-output-1-before-spacebar-toggle',
|
||||
{
|
||||
regions: regionsToMask,
|
||||
}
|
||||
);
|
||||
await terminal.sendInput(' ');
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'spacebar-terminal-output-2-after-spacebar-toggle',
|
||||
{
|
||||
regions: [regionsToMask[0]], // only the task list dynamic data should need to be masked
|
||||
}
|
||||
);
|
||||
|
||||
// Navigate down with arrow key to switch the terminal output pane contents to the second task
|
||||
await terminal.sendInput(KEY_SEQUENCES.DOWN_ARROW);
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'spacebar-terminal-output-3-after-down-arrow-nav',
|
||||
{
|
||||
regions: [regionsToMask[0]], // only the task list dynamic data should need to be masked
|
||||
}
|
||||
);
|
||||
|
||||
// Toggle the terminal output pane closed again with the spacebar
|
||||
await terminal.sendInput(' ');
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'spacebar-terminal-output-4-after-spacebar-toggle',
|
||||
{
|
||||
regions: regionsToMask,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('pressing 1 should pin a single terminal output pane, the contents of which should not change as the tasks list is navigated', async ({
|
||||
terminal,
|
||||
page,
|
||||
}) => {
|
||||
// Spawn the Nx TUI process
|
||||
await spawnCommand(
|
||||
terminal,
|
||||
page,
|
||||
'npx nx run-many --target=test --parallel=1',
|
||||
tmpProjPath()
|
||||
);
|
||||
|
||||
// Wait for the tasks to have completed
|
||||
await terminal.waitForText('Completed 2 tasks in', 15000);
|
||||
|
||||
const regionsToMask = [
|
||||
{ left: 249, top: 57, width: 50, height: 19 },
|
||||
{ left: 1223, top: 95, width: 50, height: 19 },
|
||||
{ left: 1223, top: 114, width: 50, height: 19 },
|
||||
];
|
||||
|
||||
// Pin one terminal output pane open with the 1 key
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'pin-1-terminal-output-1-before-1-key-press',
|
||||
{
|
||||
regions: regionsToMask,
|
||||
}
|
||||
);
|
||||
await terminal.sendInput('1');
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'pin-1-terminal-output-2-after-1-key-press',
|
||||
{
|
||||
regions: [regionsToMask[0]], // only the task list dynamic data should need to be masked
|
||||
}
|
||||
);
|
||||
|
||||
// Navigate down with arrow key to switch to the second task WITHOUT changing the pinned terminal output pane contents
|
||||
await terminal.sendInput(KEY_SEQUENCES.DOWN_ARROW);
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'pin-1-terminal-output-3-after-down-arrow-nav',
|
||||
{
|
||||
regions: [regionsToMask[0]], // only the task list dynamic data should need to be masked
|
||||
}
|
||||
);
|
||||
|
||||
// Pin the second task to the terminal output pane with the 1 key
|
||||
await terminal.sendInput('1');
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'pin-1-terminal-output-4-after-1-key-press-on-second-task',
|
||||
{
|
||||
regions: [regionsToMask[0]], // only the task list dynamic data should need to be masked
|
||||
}
|
||||
);
|
||||
|
||||
// Unpin the selected (second) task from the terminal output pane by pressing the 1 key again while it is still selected
|
||||
await terminal.sendInput('1');
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'pin-1-terminal-output-5-after-1-key-press-again-on-second-task',
|
||||
{
|
||||
regions: regionsToMask,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('pressing 2 should pin two output panes, the contents of which should not change as the tasks list is navigated', async ({
|
||||
terminal,
|
||||
page,
|
||||
}) => {
|
||||
// Spawn the Nx TUI process
|
||||
await spawnCommand(
|
||||
terminal,
|
||||
page,
|
||||
'npx nx run-many --target=test --parallel=1',
|
||||
tmpProjPath()
|
||||
);
|
||||
|
||||
// Wait for the tasks to have completed
|
||||
await terminal.waitForText('Completed 2 tasks in', 15000);
|
||||
|
||||
const regionsToMask = [
|
||||
{ left: 249, top: 57, width: 50, height: 19 },
|
||||
{ left: 1223, top: 95, width: 50, height: 19 },
|
||||
{ left: 1223, top: 114, width: 50, height: 19 },
|
||||
];
|
||||
|
||||
// Pin two terminal output panes open with the 2 key
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'pin-2-terminal-outputs-1-before-2-key-press',
|
||||
{
|
||||
regions: regionsToMask,
|
||||
}
|
||||
);
|
||||
await terminal.sendInput('2');
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'pin-2-terminal-outputs-2-after-2-key-press',
|
||||
{
|
||||
regions: [regionsToMask[0]], // only the task list dynamic data should need to be masked
|
||||
}
|
||||
);
|
||||
|
||||
// Navigate down with arrow key to switch to the second task WITHOUT changing the pinned terminal output pane contents
|
||||
await terminal.sendInput(KEY_SEQUENCES.DOWN_ARROW);
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'pin-2-terminal-outputs-3-after-down-arrow-nav',
|
||||
{
|
||||
regions: [regionsToMask[0]], // only the task list dynamic data should need to be masked
|
||||
}
|
||||
);
|
||||
|
||||
// Pin the second task to the first terminal output pane with the 1 key
|
||||
await terminal.sendInput('1');
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'pin-2-terminal-outputs-4-after-1-key-press-on-second-task',
|
||||
{
|
||||
regions: [regionsToMask[0]], // only the task list dynamic data should need to be masked
|
||||
}
|
||||
);
|
||||
|
||||
// Press the spacebar to toggle both panes closed at once
|
||||
await terminal.sendInput(' ');
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'pin-2-terminal-outputs-5-after-spacebar-toggle',
|
||||
{
|
||||
regions: regionsToMask,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Terminal output pane focus and scrolling', () => {
|
||||
test('pressing tab and shift+tab should cycle the focus between the task list and terminal output panes', async ({
|
||||
terminal,
|
||||
page,
|
||||
}) => {
|
||||
// Spawn the Nx TUI process
|
||||
await spawnCommand(
|
||||
terminal,
|
||||
page,
|
||||
'npx nx run-many --target=test --parallel=1',
|
||||
tmpProjPath()
|
||||
);
|
||||
|
||||
// Wait for the tasks to have completed
|
||||
await terminal.waitForText('Completed 2 tasks in', 15000);
|
||||
|
||||
// Define regions to mask, but don't mask the entire UI elements
|
||||
// We want to see the focus changes (brightness differences)
|
||||
const regionsToMask = [
|
||||
{ left: 249, top: 57, width: 50, height: 19 }, // Only mask dynamic data, not the entire task list
|
||||
];
|
||||
|
||||
// Toggle the first terminal output pane open with the spacebar
|
||||
await terminal.sendInput(' ');
|
||||
|
||||
// Take a baseline screenshot before any focus changes
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'tab-focus-1-baseline-before-focus-changes',
|
||||
{
|
||||
regions: regionsToMask,
|
||||
}
|
||||
);
|
||||
|
||||
// Press the tab key to cycle focus from the tasks list to the first terminal output pane
|
||||
await terminal.sendInput(KEY_SEQUENCES.TAB);
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'tab-focus-2-after-tab-key-press',
|
||||
{
|
||||
regions: regionsToMask,
|
||||
}
|
||||
);
|
||||
|
||||
// Press tab again to cycle focus from the first terminal output pane to the tasks list
|
||||
await terminal.sendInput(KEY_SEQUENCES.TAB);
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'tab-focus-3-after-tab-key-press',
|
||||
{
|
||||
regions: regionsToMask,
|
||||
}
|
||||
);
|
||||
|
||||
// Press tab again to cycle focus from the tasks list to the first terminal output pane, and then shift+tab to
|
||||
// cycle focus back to the tasks list to show that that also works in reverse
|
||||
await terminal.sendInput(KEY_SEQUENCES.TAB);
|
||||
await terminal.sendInput(KEY_SEQUENCES.SHIFT_TAB);
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'tab-focus-4-after-tab-and-shift-tab-key-presses',
|
||||
{
|
||||
regions: regionsToMask,
|
||||
}
|
||||
);
|
||||
|
||||
// Press the 2 key to pin two terminal output panes open
|
||||
await terminal.sendInput('2');
|
||||
|
||||
// Press the tab key to cycle focus from the tasks list to the first terminal output pane
|
||||
await terminal.sendInput(KEY_SEQUENCES.TAB);
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'tab-focus-5-after-tab-key-press-on-tasks-list',
|
||||
{
|
||||
regions: regionsToMask,
|
||||
}
|
||||
);
|
||||
|
||||
// Press tab again to cycle focus from the first terminal output pane to the second terminal output pane
|
||||
await terminal.sendInput(KEY_SEQUENCES.TAB);
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'tab-focus-6-after-tab-key-press-on-first-terminal-output-pane',
|
||||
{
|
||||
regions: regionsToMask,
|
||||
}
|
||||
);
|
||||
|
||||
// Press shift+tab again to cycle focus from the second terminal output pane to the first terminal output pane
|
||||
await terminal.sendInput(KEY_SEQUENCES.SHIFT_TAB);
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'tab-focus-7-after-shift-tab-key-press-on-second-terminal-output-pane',
|
||||
{
|
||||
regions: regionsToMask,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('each terminal output pane should be scrollable when there is more content than fits in the pane, and scroll independently of the other panes', async ({
|
||||
terminal,
|
||||
page,
|
||||
}) => {
|
||||
// Add a new "scrollable" target to each project with a variable amount of lines of output
|
||||
const scriptWith100Lines =
|
||||
'const lines = Array.from({ length: 100 }, (_, i) => `line ${i + 1}`); console.log(lines.join("\\n"));';
|
||||
const scriptWith40Lines =
|
||||
'const lines = Array.from({ length: 40 }, (_, i) => `line ${i + 1}`); console.log(lines.join("\\n"));';
|
||||
|
||||
updateFile(`my-pkg-1/script-with-100-lines.js`, scriptWith100Lines);
|
||||
updateJson(`my-pkg-1/package.json`, (json) => {
|
||||
json.scripts['scrollable'] = 'node script-with-100-lines.js';
|
||||
return json;
|
||||
});
|
||||
|
||||
updateFile(`my-pkg-2/script-with-40-lines.js`, scriptWith40Lines);
|
||||
updateJson(`my-pkg-2/package.json`, (json) => {
|
||||
json.scripts['scrollable'] = 'node script-with-40-lines.js';
|
||||
return json;
|
||||
});
|
||||
|
||||
updateJson(`nx.json`, (json) => {
|
||||
json.targetDefaults = {
|
||||
...json.targetDefaults,
|
||||
scrollable: {
|
||||
dependsOn: ['^scrollable'],
|
||||
},
|
||||
};
|
||||
return json;
|
||||
});
|
||||
|
||||
// Spawn the Nx TUI process
|
||||
await spawnCommand(
|
||||
terminal,
|
||||
page,
|
||||
'npx nx run-many --target=scrollable --parallel=1',
|
||||
tmpProjPath()
|
||||
);
|
||||
|
||||
// Wait for the tasks to have completed
|
||||
await terminal.waitForText('Completed 2 tasks in', 15000);
|
||||
|
||||
// Only the task list dynamic data should need to be masked
|
||||
const regionsToMask = [{ left: 249, top: 57, width: 50, height: 19 }];
|
||||
|
||||
// Pin each task to its own terminal output pane
|
||||
await terminal.sendInput('1');
|
||||
await terminal.sendInput(KEY_SEQUENCES.DOWN_ARROW);
|
||||
await terminal.sendInput('2');
|
||||
|
||||
// Take a baseline screenshot before any scrolling
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'scrollable-1-baseline-before-scrolling',
|
||||
{
|
||||
regions: regionsToMask,
|
||||
}
|
||||
);
|
||||
|
||||
// Focus on the first terminal output pane, which will have the cursor at the bottom of the output, and scroll it up by 10 lines
|
||||
await terminal.sendInput(KEY_SEQUENCES.TAB);
|
||||
await terminal.sendInput(KEY_SEQUENCES.UP_ARROW.repeat(10));
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'scrollable-2-after-first-terminal-output-pane-scroll-10-lines-up',
|
||||
{
|
||||
regions: regionsToMask,
|
||||
}
|
||||
);
|
||||
|
||||
// Scroll it back down by 15 lines to show it can't go past the bottom
|
||||
await terminal.sendInput(KEY_SEQUENCES.DOWN_ARROW.repeat(15));
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'scrollable-3-after-first-terminal-output-pane-scroll-back-down-15-lines',
|
||||
{
|
||||
regions: regionsToMask,
|
||||
}
|
||||
);
|
||||
|
||||
// Focus on the second terminal output pane and attempt to scroll it up past the start by scrolling up 50 lines, and it should finish at the start
|
||||
// Terminal output pane 1 should be unaffected by this
|
||||
await terminal.sendInput(KEY_SEQUENCES.TAB);
|
||||
await terminal.sendInput(KEY_SEQUENCES.UP_ARROW.repeat(50));
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'scrollable-4-after-second-terminal-output-pane-scroll-up-past-start',
|
||||
{
|
||||
regions: regionsToMask,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Interactive tasks', () => {
|
||||
test('should allow the user to interact with a running task', async ({
|
||||
terminal,
|
||||
page,
|
||||
}) => {
|
||||
const interactiveScript = `
|
||||
// Script which will print whatever it gets via stdin after an initial log, and does not clear the screen
|
||||
|
||||
setTimeout(() => {
|
||||
console.log("Initial log...\\n");
|
||||
|
||||
const readline = require("readline");
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
rl.on("line", (line) => {
|
||||
console.log(\`Received: \${line}\`);
|
||||
});
|
||||
|
||||
rl.on("close", () => {
|
||||
process.exit(0);
|
||||
});
|
||||
}, 100);
|
||||
`;
|
||||
|
||||
const target: any = {
|
||||
// Mark as continuous
|
||||
continuous: true,
|
||||
executor: 'nx:run-commands',
|
||||
options: {
|
||||
commands: ['node interactive-script.js'],
|
||||
},
|
||||
};
|
||||
|
||||
updateFile(`my-pkg-1/interactive-script.js`, interactiveScript);
|
||||
updateJson(`my-pkg-1/package.json`, (json) => {
|
||||
target.options.cwd = 'my-pkg-1';
|
||||
json.nx = {
|
||||
targets: {
|
||||
interactive: target,
|
||||
},
|
||||
};
|
||||
return json;
|
||||
});
|
||||
|
||||
updateFile(`my-pkg-2/interactive-script.js`, interactiveScript);
|
||||
updateJson(`my-pkg-2/package.json`, (json) => {
|
||||
target.options.cwd = 'my-pkg-2';
|
||||
json.nx = {
|
||||
targets: {
|
||||
interactive: target,
|
||||
},
|
||||
};
|
||||
return json;
|
||||
});
|
||||
|
||||
await spawnCommand(
|
||||
terminal,
|
||||
page,
|
||||
'npx nx run-many --target=interactive',
|
||||
tmpProjPath()
|
||||
);
|
||||
|
||||
// Wait for the tasks to have rendered (they will not complete)
|
||||
await terminal.waitForText('Executing 2/2 remaining tasks...', 15000);
|
||||
|
||||
// We need to cover the task spinners
|
||||
const regionsToMask = [
|
||||
{ left: 20, top: 95, width: 40, height: 19 },
|
||||
{ left: 20, top: 114, width: 40, height: 19 },
|
||||
];
|
||||
|
||||
// Pin the two tasks to their own terminal output panes
|
||||
await terminal.sendInput('1');
|
||||
await terminal.sendInput(KEY_SEQUENCES.DOWN_ARROW);
|
||||
await terminal.sendInput('2');
|
||||
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'interactive-1-baseline-before-interaction',
|
||||
{
|
||||
regions: regionsToMask,
|
||||
}
|
||||
);
|
||||
|
||||
// Tab to the first terminal output pane, make it interactive and type abc123
|
||||
await terminal.sendInput(KEY_SEQUENCES.TAB);
|
||||
await terminal.sendInput('i');
|
||||
await terminal.sendInput('abc123');
|
||||
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'interactive-2-after-interaction-on-first-terminal-output-pane',
|
||||
{
|
||||
regions: regionsToMask,
|
||||
}
|
||||
);
|
||||
|
||||
// Exit interactive mode on the first terminal output pane and tab to the second, make it interactive and type def456
|
||||
await terminal.sendInput(KEY_SEQUENCES.CTRL_Z);
|
||||
await terminal.sendInput(KEY_SEQUENCES.TAB);
|
||||
await terminal.sendInput('i');
|
||||
await terminal.sendInput('def456');
|
||||
|
||||
await terminal.captureSnapshotWithMasking(
|
||||
'interactive-3-after-interaction-on-second-terminal-output-pane',
|
||||
{
|
||||
regions: regionsToMask,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["dom"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": [],
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"lib": ["dom"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": [
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/*.spec.tsx",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.js",
|
||||
"**/*.test.js",
|
||||
"**/*.spec.jsx",
|
||||
"**/*.test.jsx",
|
||||
"**/*.d.ts",
|
||||
"playwright.config.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as chalk from 'chalk';
|
||||
import chalk = require('chalk');
|
||||
|
||||
export const E2E_LOG_PREFIX = `${chalk.reset.inverse.bold.keyword('orange')(
|
||||
' E2E '
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { promisify } from 'util';
|
||||
import * as treeKill from 'tree-kill';
|
||||
import treeKill = require('tree-kill');
|
||||
import { logError, logInfo, logSuccess } from './log-utils';
|
||||
import { check as portCheck } from 'tcp-port-used';
|
||||
|
||||
|
||||
@@ -181,7 +181,10 @@
|
||||
}
|
||||
},
|
||||
"plugins": [
|
||||
"@monodon/rust",
|
||||
{
|
||||
"plugin": "@monodon/rust",
|
||||
"exclude": ["packages/nx/src/native/tui"]
|
||||
},
|
||||
{
|
||||
"plugin": "@nx/playwright/plugin",
|
||||
"options": {
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
"@nx/web": "20.5.0-beta.2",
|
||||
"@nx/webpack": "20.5.0-beta.2",
|
||||
"@phenomnomnominal/tsquery": "~5.0.1",
|
||||
"@playwright/test": "^1.36.1",
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.7",
|
||||
"@pnpm/lockfile-types": "^6.0.0",
|
||||
"@reduxjs/toolkit": "1.9.0",
|
||||
@@ -314,6 +314,9 @@
|
||||
"webpack-node-externals": "^3.0.0",
|
||||
"webpack-subresource-integrity": "^5.1.0",
|
||||
"xstate": "4.34.0",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"xterm-addon-serialize": "^0.11.0",
|
||||
"yaml": "^2.6.0",
|
||||
"yargs": "17.6.2",
|
||||
"yargs-parser": "21.1.1"
|
||||
@@ -392,5 +395,8 @@
|
||||
"check-codeowners"
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@9.8.0"
|
||||
"packageManager": "pnpm@9.8.0",
|
||||
"optionalDependencies": {
|
||||
"node-pty": "^1.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,6 +223,7 @@ describe('app', () => {
|
||||
"buildTarget": "myapp:build:production",
|
||||
},
|
||||
},
|
||||
"continuous": true,
|
||||
"defaultConfiguration": "development",
|
||||
"dependsOn": [
|
||||
"build",
|
||||
|
||||
@@ -2213,6 +2213,7 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => {
|
||||
},
|
||||
"watch-deps": {
|
||||
"command": "npx nx watch --projects my-lib --includeDependentProjects -- npx nx build-deps my-lib",
|
||||
"continuous": true,
|
||||
"dependsOn": [
|
||||
"build-deps",
|
||||
],
|
||||
@@ -2367,6 +2368,7 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => {
|
||||
},
|
||||
"watch-deps": {
|
||||
"command": "npx nx watch --projects my-lib --includeDependentProjects -- npx nx build-deps my-lib",
|
||||
"continuous": true,
|
||||
"dependsOn": [
|
||||
"build-deps",
|
||||
],
|
||||
|
||||
@@ -34,6 +34,7 @@ export function addBuildAndWatchDepsTargets(
|
||||
dependsOn: ['^build'],
|
||||
};
|
||||
targets[options.watchDepsTargetName ?? 'watch-deps'] = {
|
||||
continuous: true,
|
||||
dependsOn: [buildDepsTargetName],
|
||||
command: `${pmc.exec} nx watch --projects ${projectName} --includeDependentProjects -- ${pmc.exec} nx ${buildDepsTargetName} ${projectName}`,
|
||||
};
|
||||
|
||||
@@ -394,6 +394,7 @@ describe('calculateDependenciesFromTaskGraph', () => {
|
||||
'lib3:build': [],
|
||||
'lib4:build': [],
|
||||
},
|
||||
continuousDependencies: {},
|
||||
roots: [],
|
||||
tasks: {
|
||||
'lib1:build': {
|
||||
@@ -402,6 +403,7 @@ describe('calculateDependenciesFromTaskGraph', () => {
|
||||
target: { project: 'lib1', target: 'build' },
|
||||
outputs: [],
|
||||
parallelism: true,
|
||||
continuous: false,
|
||||
},
|
||||
'lib2:build': {
|
||||
id: 'lib2:build',
|
||||
@@ -409,6 +411,7 @@ describe('calculateDependenciesFromTaskGraph', () => {
|
||||
target: { project: 'lib2', target: 'build' },
|
||||
outputs: [],
|
||||
parallelism: true,
|
||||
continuous: false,
|
||||
},
|
||||
'lib2:build-base': {
|
||||
id: 'lib2:build-base',
|
||||
@@ -416,6 +419,7 @@ describe('calculateDependenciesFromTaskGraph', () => {
|
||||
target: { project: 'lib2', target: 'build-base' },
|
||||
outputs: [],
|
||||
parallelism: true,
|
||||
continuous: false,
|
||||
},
|
||||
'lib3:build': {
|
||||
id: 'lib3:build',
|
||||
@@ -423,6 +427,7 @@ describe('calculateDependenciesFromTaskGraph', () => {
|
||||
target: { project: 'lib3', target: 'build' },
|
||||
outputs: [],
|
||||
parallelism: true,
|
||||
continuous: false,
|
||||
},
|
||||
'lib4:build': {
|
||||
id: 'lib4:build',
|
||||
@@ -430,6 +435,7 @@ describe('calculateDependenciesFromTaskGraph', () => {
|
||||
target: { project: 'lib4', target: 'build' },
|
||||
outputs: [],
|
||||
parallelism: true,
|
||||
continuous: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -569,6 +575,7 @@ describe('calculateDependenciesFromTaskGraph', () => {
|
||||
'lib4:build': ['lib4:build-base'],
|
||||
'lib4:build-base': [],
|
||||
},
|
||||
continuousDependencies: {},
|
||||
roots: [],
|
||||
tasks: {
|
||||
'lib1:build': {
|
||||
@@ -577,6 +584,7 @@ describe('calculateDependenciesFromTaskGraph', () => {
|
||||
target: { project: 'lib1', target: 'build' },
|
||||
outputs: [],
|
||||
parallelism: true,
|
||||
continuous: false,
|
||||
},
|
||||
'lib1:build-base': {
|
||||
id: 'lib1:build-base',
|
||||
@@ -584,6 +592,7 @@ describe('calculateDependenciesFromTaskGraph', () => {
|
||||
target: { project: 'lib1', target: 'build-base' },
|
||||
outputs: [],
|
||||
parallelism: true,
|
||||
continuous: false,
|
||||
},
|
||||
'lib2:build': {
|
||||
id: 'lib2:build',
|
||||
@@ -591,6 +600,7 @@ describe('calculateDependenciesFromTaskGraph', () => {
|
||||
target: { project: 'lib2', target: 'build' },
|
||||
outputs: [],
|
||||
parallelism: true,
|
||||
continuous: false,
|
||||
},
|
||||
'lib2:build-base': {
|
||||
id: 'lib2:build-base',
|
||||
@@ -598,6 +608,7 @@ describe('calculateDependenciesFromTaskGraph', () => {
|
||||
target: { project: 'lib2', target: 'build-base' },
|
||||
outputs: [],
|
||||
parallelism: true,
|
||||
continuous: false,
|
||||
},
|
||||
'lib3:build': {
|
||||
id: 'lib3:build',
|
||||
@@ -605,6 +616,7 @@ describe('calculateDependenciesFromTaskGraph', () => {
|
||||
target: { project: 'lib3', target: 'build' },
|
||||
outputs: [],
|
||||
parallelism: true,
|
||||
continuous: false,
|
||||
},
|
||||
'lib3:build-base': {
|
||||
id: 'lib3:build-base',
|
||||
@@ -612,6 +624,7 @@ describe('calculateDependenciesFromTaskGraph', () => {
|
||||
target: { project: 'lib3', target: 'build-base' },
|
||||
outputs: [],
|
||||
parallelism: true,
|
||||
continuous: false,
|
||||
},
|
||||
'lib4:build': {
|
||||
id: 'lib4:build',
|
||||
@@ -619,6 +632,7 @@ describe('calculateDependenciesFromTaskGraph', () => {
|
||||
target: { project: 'lib4', target: 'build' },
|
||||
outputs: [],
|
||||
parallelism: true,
|
||||
continuous: false,
|
||||
},
|
||||
'lib4:build-base': {
|
||||
id: 'lib4:build-base',
|
||||
@@ -626,6 +640,7 @@ describe('calculateDependenciesFromTaskGraph', () => {
|
||||
target: { project: 'lib4', target: 'build-base' },
|
||||
outputs: [],
|
||||
parallelism: true,
|
||||
continuous: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -717,6 +732,7 @@ describe('calculateDependenciesFromTaskGraph', () => {
|
||||
// not relevant for this test case
|
||||
const taskGraph: TaskGraph = {
|
||||
dependencies: {},
|
||||
continuousDependencies: {},
|
||||
roots: [],
|
||||
tasks: {},
|
||||
};
|
||||
|
||||
@@ -56,6 +56,7 @@ describe('application generator', () => {
|
||||
"buildTarget": "my-node-app:build:production",
|
||||
},
|
||||
},
|
||||
"continuous": true,
|
||||
"defaultConfiguration": "development",
|
||||
"dependsOn": [
|
||||
"build",
|
||||
@@ -238,6 +239,7 @@ describe('application generator', () => {
|
||||
"buildTarget": "myapp:build:production",
|
||||
},
|
||||
},
|
||||
"continuous": true,
|
||||
"defaultConfiguration": "development",
|
||||
"dependsOn": [
|
||||
"build",
|
||||
|
||||
@@ -64,6 +64,7 @@ exports[`@nx/next/plugin integrated projects should create nodes 1`] = `
|
||||
},
|
||||
"watch-deps": {
|
||||
"command": "npx nx watch --projects my-app --includeDependentProjects -- npx nx build-deps my-app",
|
||||
"continuous": true,
|
||||
"dependsOn": [
|
||||
"build-deps",
|
||||
],
|
||||
@@ -140,6 +141,7 @@ exports[`@nx/next/plugin root projects should create nodes 1`] = `
|
||||
},
|
||||
"watch-deps": {
|
||||
"command": "npx nx watch --projects next --includeDependentProjects -- npx nx build-deps next",
|
||||
"continuous": true,
|
||||
"dependsOn": [
|
||||
"build-deps",
|
||||
],
|
||||
|
||||
@@ -51,6 +51,7 @@ describe('app', () => {
|
||||
"buildTarget": "my-node-app:build:production",
|
||||
},
|
||||
},
|
||||
"continuous": true,
|
||||
"defaultConfiguration": "development",
|
||||
"dependsOn": [
|
||||
"build",
|
||||
@@ -263,6 +264,7 @@ describe('app', () => {
|
||||
"buildTarget": "my-node-app:build:production",
|
||||
},
|
||||
},
|
||||
"continuous": true,
|
||||
"defaultConfiguration": "development",
|
||||
"dependsOn": [
|
||||
"build",
|
||||
@@ -626,6 +628,7 @@ describe('app', () => {
|
||||
"buildTarget": "myapp:build:production",
|
||||
},
|
||||
},
|
||||
"continuous": true,
|
||||
"defaultConfiguration": "development",
|
||||
"dependsOn": [
|
||||
"build",
|
||||
|
||||
@@ -145,6 +145,7 @@ function getEsBuildConfig(
|
||||
|
||||
function getServeConfig(options: NormalizedSchema): TargetConfiguration {
|
||||
return {
|
||||
continuous: true,
|
||||
executor: '@nx/js:node',
|
||||
defaultConfiguration: 'development',
|
||||
// Run build, which includes dependency on "^build" by default, so the first run
|
||||
@@ -290,10 +291,18 @@ function addAppFiles(tree: Tree, options: NormalizedSchema) {
|
||||
|
||||
function addProxy(tree: Tree, options: NormalizedSchema) {
|
||||
const projectConfig = readProjectConfiguration(tree, options.frontendProject);
|
||||
if (projectConfig.targets && projectConfig.targets.serve) {
|
||||
if (
|
||||
projectConfig.targets &&
|
||||
['serve', 'dev'].find((t) => !!projectConfig.targets[t])
|
||||
) {
|
||||
const targetName = ['serve', 'dev'].find((t) => !!projectConfig.targets[t]);
|
||||
projectConfig.targets[targetName].dependsOn = [
|
||||
...(projectConfig.targets[targetName].dependsOn ?? []),
|
||||
`${options.name}:serve`,
|
||||
];
|
||||
const pathToProxyFile = `${projectConfig.root}/proxy.conf.json`;
|
||||
projectConfig.targets.serve.options = {
|
||||
...projectConfig.targets.serve.options,
|
||||
projectConfig.targets[targetName].options = {
|
||||
...projectConfig.targets[targetName].options,
|
||||
proxyConfig: pathToProxyFile,
|
||||
};
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ exports[`@nx/nuxt/plugin not root project should create nodes 1`] = `
|
||||
},
|
||||
"watch-deps": {
|
||||
"command": "npx nx watch --projects my-app --includeDependentProjects -- npx nx build-deps my-app",
|
||||
"continuous": true,
|
||||
"dependsOn": [
|
||||
"build-deps",
|
||||
],
|
||||
@@ -158,6 +159,7 @@ exports[`@nx/nuxt/plugin root project should create nodes 1`] = `
|
||||
},
|
||||
"watch-deps": {
|
||||
"command": "npx nx watch --projects nuxt --includeDependentProjects -- npx nx build-deps nuxt",
|
||||
"continuous": true,
|
||||
"dependsOn": [
|
||||
"build-deps",
|
||||
],
|
||||
|
||||
@@ -13,36 +13,58 @@ strip = "none"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.71"
|
||||
arboard = "3.4.1"
|
||||
better-panic = "0.3.0"
|
||||
colored = "2"
|
||||
color-eyre = "0.6.3"
|
||||
crossbeam-channel = '0.5'
|
||||
crossterm = { version = "0.27.0", features = ["serde", "event-stream"] }
|
||||
dashmap = { version = "5.5.3", features = ["rayon"] }
|
||||
dunce = "1"
|
||||
fs_extra = "1.3.0"
|
||||
futures = "0.3.28"
|
||||
globset = "0.4.10"
|
||||
hashbrown = { version = "0.14.5", features = ["rayon", "rkyv"] }
|
||||
human-panic = "1.2.0"
|
||||
ignore = '0.4'
|
||||
itertools = "0.10.5"
|
||||
libc = "0.2.148"
|
||||
log = "0.4.20"
|
||||
once_cell = "1.18.0"
|
||||
parking_lot = { version = "0.12.1", features = ["send_guard"] }
|
||||
napi = { version = '2.16.0', default-features = false, features = [
|
||||
'anyhow',
|
||||
'napi4',
|
||||
'tokio_rt',
|
||||
napi = { version = "2.16.0", default-features = false, features = [
|
||||
"anyhow",
|
||||
"napi4",
|
||||
"tokio_rt",
|
||||
"serde-json",
|
||||
"async",
|
||||
"chrono_date"
|
||||
] }
|
||||
napi-derive = '2.16.0'
|
||||
napi-derive = { version = "2.16.0", features = ["type-def"] }
|
||||
nom = '7.1.3'
|
||||
# TODO: remove this should no longer be needed
|
||||
rand = "0.9.0"
|
||||
regex = "1.9.1"
|
||||
ratatui = { version = "0.29", features = ["scrolling-regions"] }
|
||||
rayon = "1.7.0"
|
||||
rkyv = { version = "0.7", features = ["validation"] }
|
||||
thiserror = "1.0.40"
|
||||
tracing = "0.1.37"
|
||||
tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }
|
||||
walkdir = '2.3.3'
|
||||
xxhash-rust = { version = '0.8.5', features = ['xxh3', 'xxh64'] }
|
||||
serde = { version = "1.0.188", features = ["derive"] }
|
||||
serde_json = "1.0.107"
|
||||
strip-ansi-escapes = "0.2.0"
|
||||
swc_common = "0.31.16"
|
||||
swc_ecma_parser = { version = "0.137.1", features = ["typescript"] }
|
||||
swc_ecma_visit = "0.93.0"
|
||||
swc_ecma_ast = "0.107.0"
|
||||
thiserror = "1.0.40"
|
||||
tracing = "0.1.37"
|
||||
tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }
|
||||
tokio = { version = "1.32.0", features = ["full"] }
|
||||
tokio-util = "0.7.9"
|
||||
tracing-appender = "0.2"
|
||||
tui-term = "0.2.0"
|
||||
walkdir = '2.3.3'
|
||||
xxhash-rust = { version = '0.8.5', features = ['xxh3', 'xxh64'] }
|
||||
vt100-ctt = { git = "https://github.com/JamesHenry/vt100-rust", rev = "1de895505fe9f697aadac585e4075b8fb45c880d" }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winapi = { version = "0.3", features = ["fileapi"] }
|
||||
@@ -50,10 +72,8 @@ winapi = { version = "0.3", features = ["fileapi"] }
|
||||
[target.'cfg(all(not(windows), not(target_family = "wasm")))'.dependencies]
|
||||
mio = "0.8"
|
||||
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
portable-pty = { git = "https://github.com/cammisuli/wezterm", rev = "b538ee29e1e89eeb4832fb35ae095564dce34c29" }
|
||||
crossterm = "0.27.0"
|
||||
ignore-files = "2.1.0"
|
||||
fs4 = "0.12.0"
|
||||
rusqlite = { version = "0.32.1", features = ["bundled", "array", "vtab"] }
|
||||
@@ -74,5 +94,3 @@ assert_fs = "1.0.10"
|
||||
# This is only used for unit tests
|
||||
swc_ecma_dep_graph = "0.109.1"
|
||||
tempfile = "3.13.0"
|
||||
# We only explicitly use tokio for async tests
|
||||
tokio = "1.38.0"
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
"string-width": "^4.2.3",
|
||||
"tar-stream": "~2.2.0",
|
||||
"tmp": "~0.2.1",
|
||||
"tree-kill": "^1.2.2",
|
||||
"tsconfig-paths": "^4.1.2",
|
||||
"tslib": "^2.3.0",
|
||||
"yaml": "^2.6.0",
|
||||
|
||||
@@ -980,6 +980,7 @@ function getAllTaskGraphsForWorkspace(projectGraph: ProjectGraph): {
|
||||
taskGraphs[taskId] = {
|
||||
tasks: {},
|
||||
dependencies: {},
|
||||
continuousDependencies: {},
|
||||
roots: [],
|
||||
};
|
||||
|
||||
@@ -1006,6 +1007,7 @@ function getAllTaskGraphsForWorkspace(projectGraph: ProjectGraph): {
|
||||
taskGraphs[taskId] = {
|
||||
tasks: {},
|
||||
dependencies: {},
|
||||
continuousDependencies: {},
|
||||
roots: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ProjectGraph, ProjectGraphProjectNode } from '../config/project-graph';
|
||||
import { removeIdsFromGraph } from '../tasks-runner/utils';
|
||||
import { NxArgs } from '../utils/command-line-utils';
|
||||
import { CommandGraph } from './command-graph';
|
||||
import { createCommandGraph } from './create-command-graph';
|
||||
@@ -34,3 +33,35 @@ function getSortedProjects(
|
||||
|
||||
return getSortedProjects(newGraph, sortedProjects);
|
||||
}
|
||||
|
||||
function removeIdsFromGraph<T>(
|
||||
graph: {
|
||||
roots: string[];
|
||||
dependencies: Record<string, string[]>;
|
||||
},
|
||||
ids: string[],
|
||||
mapWithIds: Record<string, T>
|
||||
): {
|
||||
mapWithIds: Record<string, T>;
|
||||
roots: string[];
|
||||
dependencies: Record<string, string[]>;
|
||||
} {
|
||||
const filteredMapWithIds = {};
|
||||
const dependencies = {};
|
||||
const removedSet = new Set(ids);
|
||||
for (let id of Object.keys(mapWithIds)) {
|
||||
if (!removedSet.has(id)) {
|
||||
filteredMapWithIds[id] = mapWithIds[id];
|
||||
dependencies[id] = graph.dependencies[id].filter(
|
||||
(depId) => !removedSet.has(depId)
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
mapWithIds: filteredMapWithIds,
|
||||
dependencies: dependencies,
|
||||
roots: Object.keys(dependencies).filter(
|
||||
(k) => dependencies[k].length === 0
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,6 +81,11 @@ export interface Task {
|
||||
* Determines if a given task should be parallelizable.
|
||||
*/
|
||||
parallelism: boolean;
|
||||
|
||||
/**
|
||||
* This denotes if the task runs continuously
|
||||
*/
|
||||
continuous?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,4 +104,6 @@ export interface TaskGraph {
|
||||
* Map of Task IDs to IDs of tasks which the task depends on
|
||||
*/
|
||||
dependencies: Record<string, string[]>;
|
||||
|
||||
continuousDependencies: Record<string, string[]>;
|
||||
}
|
||||
|
||||
@@ -258,6 +258,11 @@ export interface TargetConfiguration<T = any> {
|
||||
*/
|
||||
parallelism?: boolean;
|
||||
|
||||
/**
|
||||
* Whether this target runs continuously
|
||||
*/
|
||||
continuous?: boolean;
|
||||
|
||||
/**
|
||||
* List of generators to run before the target to ensure the workspace
|
||||
* is up to date.
|
||||
|
||||
@@ -1,54 +1,40 @@
|
||||
import { ChildProcess, exec, Serializable } from 'child_process';
|
||||
import * as path from 'path';
|
||||
import { Serializable } from 'child_process';
|
||||
import * as yargsParser from 'yargs-parser';
|
||||
import { env as appendLocalEnv } from 'npm-run-path';
|
||||
import { ExecutorContext } from '../../config/misc-interfaces';
|
||||
import * as chalk from 'chalk';
|
||||
import {
|
||||
getPseudoTerminal,
|
||||
createPseudoTerminal,
|
||||
PseudoTerminal,
|
||||
PseudoTtyProcess,
|
||||
} from '../../tasks-runner/pseudo-terminal';
|
||||
import { signalToCode } from '../../utils/exit-codes';
|
||||
import {
|
||||
loadAndExpandDotEnvFile,
|
||||
unloadDotEnvFile,
|
||||
} from '../../tasks-runner/task-env';
|
||||
ParallelRunningTasks,
|
||||
runSingleCommandWithPseudoTerminal,
|
||||
SeriallyRunningTasks,
|
||||
} from './running-tasks';
|
||||
|
||||
export const LARGE_BUFFER = 1024 * 1000000;
|
||||
let pseudoTerminal: PseudoTerminal | null;
|
||||
const childProcesses = new Set<ChildProcess | PseudoTtyProcess>();
|
||||
|
||||
function loadEnvVarsFile(path: string, env: Record<string, string> = {}) {
|
||||
unloadDotEnvFile(path, env);
|
||||
const result = loadAndExpandDotEnvFile(path, env);
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
}
|
||||
|
||||
export type Json = {
|
||||
[k: string]: any;
|
||||
};
|
||||
|
||||
export interface RunCommandsCommandOptions {
|
||||
command: string;
|
||||
forwardAllArgs?: boolean;
|
||||
/**
|
||||
* description was added to allow users to document their commands inline,
|
||||
* it is not intended to be used as part of the execution of the command.
|
||||
*/
|
||||
description?: string;
|
||||
prefix?: string;
|
||||
prefixColor?: string;
|
||||
color?: string;
|
||||
bgColor?: string;
|
||||
}
|
||||
|
||||
export interface RunCommandsOptions extends Json {
|
||||
command?: string | string[];
|
||||
commands?: (
|
||||
| {
|
||||
command: string;
|
||||
forwardAllArgs?: boolean;
|
||||
/**
|
||||
* description was added to allow users to document their commands inline,
|
||||
* it is not intended to be used as part of the execution of the command.
|
||||
*/
|
||||
description?: string;
|
||||
prefix?: string;
|
||||
prefixColor?: string;
|
||||
color?: string;
|
||||
bgColor?: string;
|
||||
}
|
||||
| string
|
||||
)[];
|
||||
commands?: Array<RunCommandsCommandOptions | string>;
|
||||
color?: boolean;
|
||||
parallel?: boolean;
|
||||
readyWhen?: string | string[];
|
||||
@@ -84,10 +70,7 @@ const propKeys = [
|
||||
];
|
||||
|
||||
export interface NormalizedRunCommandsOptions extends RunCommandsOptions {
|
||||
commands: {
|
||||
command: string;
|
||||
forwardAllArgs?: boolean;
|
||||
}[];
|
||||
commands: Array<RunCommandsCommandOptions>;
|
||||
unknownOptions?: {
|
||||
[k: string]: any;
|
||||
};
|
||||
@@ -108,7 +91,18 @@ export default async function (
|
||||
success: boolean;
|
||||
terminalOutput: string;
|
||||
}> {
|
||||
registerProcessListener();
|
||||
const task = await runCommands(options, context);
|
||||
const results = await task.getResults();
|
||||
return {
|
||||
...results,
|
||||
success: results.code === 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runCommands(
|
||||
options: RunCommandsOptions,
|
||||
context: ExecutorContext
|
||||
) {
|
||||
const normalized = normalizeOptions(options);
|
||||
|
||||
if (normalized.readyWhenStatus.length && !normalized.parallel) {
|
||||
@@ -128,11 +122,33 @@ export default async function (
|
||||
);
|
||||
}
|
||||
|
||||
const isSingleCommand = normalized.commands.length === 1;
|
||||
|
||||
const pseudoTerminal =
|
||||
(isSingleCommand || !options.parallel) && PseudoTerminal.isSupported()
|
||||
? createPseudoTerminal()
|
||||
: null;
|
||||
|
||||
const isSingleCommandAndCanUsePseudoTerminal =
|
||||
isSingleCommand &&
|
||||
pseudoTerminal &&
|
||||
process.env.NX_NATIVE_COMMAND_RUNNER !== 'false' &&
|
||||
!normalized.commands[0].prefix &&
|
||||
normalized.usePty;
|
||||
|
||||
try {
|
||||
const result = options.parallel
|
||||
? await runInParallel(normalized, context)
|
||||
: await runSerially(normalized, context);
|
||||
return result;
|
||||
const runningTask = isSingleCommandAndCanUsePseudoTerminal
|
||||
? await runSingleCommandWithPseudoTerminal(
|
||||
normalized,
|
||||
context,
|
||||
pseudoTerminal
|
||||
)
|
||||
: options.parallel
|
||||
? new ParallelRunningTasks(normalized, context)
|
||||
: new SeriallyRunningTasks(normalized, context, pseudoTerminal);
|
||||
|
||||
registerProcessListener(runningTask, pseudoTerminal);
|
||||
return runningTask;
|
||||
} catch (e) {
|
||||
if (process.env.NX_VERBOSE_LOGGING === 'true') {
|
||||
console.error(e);
|
||||
@@ -143,78 +159,7 @@ export default async function (
|
||||
}
|
||||
}
|
||||
|
||||
async function runInParallel(
|
||||
options: NormalizedRunCommandsOptions,
|
||||
context: ExecutorContext
|
||||
): Promise<{ success: boolean; terminalOutput: string }> {
|
||||
const procs = options.commands.map((c) =>
|
||||
createProcess(
|
||||
null,
|
||||
c,
|
||||
options.readyWhenStatus,
|
||||
options.color,
|
||||
calculateCwd(options.cwd, context),
|
||||
options.env ?? {},
|
||||
true,
|
||||
options.usePty,
|
||||
options.streamOutput,
|
||||
options.tty,
|
||||
options.envFile
|
||||
).then((result: { success: boolean; terminalOutput: string }) => ({
|
||||
result,
|
||||
command: c.command,
|
||||
}))
|
||||
);
|
||||
|
||||
let terminalOutput = '';
|
||||
if (options.readyWhenStatus.length) {
|
||||
const r: {
|
||||
result: { success: boolean; terminalOutput: string };
|
||||
command: string;
|
||||
} = await Promise.race(procs);
|
||||
terminalOutput += r.result.terminalOutput;
|
||||
if (!r.result.success) {
|
||||
const output = `Warning: command "${r.command}" exited with non-zero status code`;
|
||||
terminalOutput += output;
|
||||
if (options.streamOutput) {
|
||||
process.stderr.write(output);
|
||||
}
|
||||
return { success: false, terminalOutput };
|
||||
} else {
|
||||
return { success: true, terminalOutput };
|
||||
}
|
||||
} else {
|
||||
const r: {
|
||||
result: { success: boolean; terminalOutput: string };
|
||||
command: string;
|
||||
}[] = await Promise.all(procs);
|
||||
terminalOutput += r.map((f) => f.result.terminalOutput).join('');
|
||||
const failed = r.filter((v) => !v.result.success);
|
||||
if (failed.length > 0) {
|
||||
const output = failed
|
||||
.map(
|
||||
(f) =>
|
||||
`Warning: command "${f.command}" exited with non-zero status code`
|
||||
)
|
||||
.join('\r\n');
|
||||
terminalOutput += output;
|
||||
if (options.streamOutput) {
|
||||
process.stderr.write(output);
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
terminalOutput,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: true,
|
||||
terminalOutput,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOptions(
|
||||
export function normalizeOptions(
|
||||
options: RunCommandsOptions
|
||||
): NormalizedRunCommandsOptions {
|
||||
if (options.readyWhen && typeof options.readyWhen === 'string') {
|
||||
@@ -279,241 +224,6 @@ function normalizeOptions(
|
||||
return options as NormalizedRunCommandsOptions;
|
||||
}
|
||||
|
||||
async function runSerially(
|
||||
options: NormalizedRunCommandsOptions,
|
||||
context: ExecutorContext
|
||||
): Promise<{ success: boolean; terminalOutput: string }> {
|
||||
pseudoTerminal ??= PseudoTerminal.isSupported() ? getPseudoTerminal() : null;
|
||||
let terminalOutput = '';
|
||||
for (const c of options.commands) {
|
||||
const result: { success: boolean; terminalOutput: string } =
|
||||
await createProcess(
|
||||
pseudoTerminal,
|
||||
c,
|
||||
[],
|
||||
options.color,
|
||||
calculateCwd(options.cwd, context),
|
||||
options.processEnv ?? options.env ?? {},
|
||||
false,
|
||||
options.usePty,
|
||||
options.streamOutput,
|
||||
options.tty,
|
||||
options.envFile
|
||||
);
|
||||
terminalOutput += result.terminalOutput;
|
||||
if (!result.success) {
|
||||
const output = `Warning: command "${c.command}" exited with non-zero status code`;
|
||||
result.terminalOutput += output;
|
||||
if (options.streamOutput) {
|
||||
process.stderr.write(output);
|
||||
}
|
||||
return { success: false, terminalOutput };
|
||||
}
|
||||
}
|
||||
return { success: true, terminalOutput };
|
||||
}
|
||||
|
||||
async function createProcess(
|
||||
pseudoTerminal: PseudoTerminal | null,
|
||||
commandConfig: {
|
||||
command: string;
|
||||
color?: string;
|
||||
bgColor?: string;
|
||||
prefix?: string;
|
||||
prefixColor?: string;
|
||||
},
|
||||
readyWhenStatus: { stringToMatch: string; found: boolean }[] = [],
|
||||
color: boolean,
|
||||
cwd: string,
|
||||
env: Record<string, string>,
|
||||
isParallel: boolean,
|
||||
usePty: boolean = true,
|
||||
streamOutput: boolean = true,
|
||||
tty: boolean,
|
||||
envFile?: string
|
||||
): Promise<{ success: boolean; terminalOutput: string }> {
|
||||
env = processEnv(color, cwd, env, envFile);
|
||||
// The rust runCommand is always a tty, so it will not look nice in parallel and if we need prefixes
|
||||
// currently does not work properly in windows
|
||||
if (
|
||||
pseudoTerminal &&
|
||||
process.env.NX_NATIVE_COMMAND_RUNNER !== 'false' &&
|
||||
!commandConfig.prefix &&
|
||||
readyWhenStatus.length === 0 &&
|
||||
!isParallel &&
|
||||
usePty
|
||||
) {
|
||||
let terminalOutput = chalk.dim('> ') + commandConfig.command + '\r\n\r\n';
|
||||
if (streamOutput) {
|
||||
process.stdout.write(terminalOutput);
|
||||
}
|
||||
|
||||
const cp = pseudoTerminal.runCommand(commandConfig.command, {
|
||||
cwd,
|
||||
jsEnv: env,
|
||||
quiet: !streamOutput,
|
||||
tty,
|
||||
});
|
||||
|
||||
childProcesses.add(cp);
|
||||
|
||||
return new Promise((res) => {
|
||||
cp.onOutput((output) => {
|
||||
terminalOutput += output;
|
||||
});
|
||||
|
||||
cp.onExit((code) => {
|
||||
if (code >= 128) {
|
||||
process.exit(code);
|
||||
} else {
|
||||
res({ success: code === 0, terminalOutput });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return nodeProcess(commandConfig, cwd, env, readyWhenStatus, streamOutput);
|
||||
}
|
||||
|
||||
function nodeProcess(
|
||||
commandConfig: {
|
||||
command: string;
|
||||
color?: string;
|
||||
bgColor?: string;
|
||||
prefix?: string;
|
||||
prefixColor?: string;
|
||||
},
|
||||
cwd: string,
|
||||
env: Record<string, string>,
|
||||
readyWhenStatus: { stringToMatch: string; found: boolean }[],
|
||||
streamOutput = true
|
||||
): Promise<{ success: boolean; terminalOutput: string }> {
|
||||
let terminalOutput = chalk.dim('> ') + commandConfig.command + '\r\n\r\n';
|
||||
if (streamOutput) {
|
||||
process.stdout.write(terminalOutput);
|
||||
}
|
||||
return new Promise((res) => {
|
||||
const childProcess = exec(commandConfig.command, {
|
||||
maxBuffer: LARGE_BUFFER,
|
||||
env,
|
||||
cwd,
|
||||
windowsHide: false,
|
||||
});
|
||||
|
||||
childProcesses.add(childProcess);
|
||||
|
||||
childProcess.stdout.on('data', (data) => {
|
||||
const output = addColorAndPrefix(data, commandConfig);
|
||||
terminalOutput += output;
|
||||
if (streamOutput) {
|
||||
process.stdout.write(output);
|
||||
}
|
||||
if (readyWhenStatus.length && isReady(readyWhenStatus, data.toString())) {
|
||||
res({ success: true, terminalOutput });
|
||||
}
|
||||
});
|
||||
childProcess.stderr.on('data', (err) => {
|
||||
const output = addColorAndPrefix(err, commandConfig);
|
||||
terminalOutput += output;
|
||||
if (streamOutput) {
|
||||
process.stderr.write(output);
|
||||
}
|
||||
if (readyWhenStatus.length && isReady(readyWhenStatus, err.toString())) {
|
||||
res({ success: true, terminalOutput });
|
||||
}
|
||||
});
|
||||
childProcess.on('error', (err) => {
|
||||
const ouptput = addColorAndPrefix(err.toString(), commandConfig);
|
||||
terminalOutput += ouptput;
|
||||
if (streamOutput) {
|
||||
process.stderr.write(ouptput);
|
||||
}
|
||||
res({ success: false, terminalOutput });
|
||||
});
|
||||
childProcess.on('exit', (code) => {
|
||||
childProcesses.delete(childProcess);
|
||||
if (!readyWhenStatus.length || isReady(readyWhenStatus)) {
|
||||
res({ success: code === 0, terminalOutput });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function addColorAndPrefix(
|
||||
out: string,
|
||||
config: {
|
||||
prefix?: string;
|
||||
prefixColor?: string;
|
||||
color?: string;
|
||||
bgColor?: string;
|
||||
}
|
||||
) {
|
||||
if (config.prefix) {
|
||||
out = out
|
||||
.split('\n')
|
||||
.map((l) => {
|
||||
let prefixText = config.prefix;
|
||||
if (config.prefixColor && chalk[config.prefixColor]) {
|
||||
prefixText = chalk[config.prefixColor](prefixText);
|
||||
}
|
||||
prefixText = chalk.bold(prefixText);
|
||||
return l.trim().length > 0 ? `${prefixText} ${l}` : l;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
if (config.color && chalk[config.color]) {
|
||||
out = chalk[config.color](out);
|
||||
}
|
||||
if (config.bgColor && chalk[config.bgColor]) {
|
||||
out = chalk[config.bgColor](out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function calculateCwd(
|
||||
cwd: string | undefined,
|
||||
context: ExecutorContext
|
||||
): string {
|
||||
if (!cwd) return context.root;
|
||||
if (path.isAbsolute(cwd)) return cwd;
|
||||
return path.join(context.root, cwd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Env variables are processed in the following order:
|
||||
* - env option from executor options
|
||||
* - env file from envFile option if provided
|
||||
* - local env variables
|
||||
*/
|
||||
function processEnv(
|
||||
color: boolean,
|
||||
cwd: string,
|
||||
envOptionFromExecutor: Record<string, string>,
|
||||
envFile?: string
|
||||
) {
|
||||
let localEnv = appendLocalEnv({ cwd: cwd ?? process.cwd() });
|
||||
localEnv = {
|
||||
...process.env,
|
||||
...localEnv,
|
||||
};
|
||||
|
||||
if (process.env.NX_LOAD_DOT_ENV_FILES !== 'false' && envFile) {
|
||||
loadEnvVarsFile(envFile, localEnv);
|
||||
}
|
||||
let res: Record<string, string> = {
|
||||
...localEnv,
|
||||
...envOptionFromExecutor,
|
||||
};
|
||||
// need to override PATH to make sure we are using the local node_modules
|
||||
if (localEnv.PATH) res.PATH = localEnv.PATH; // UNIX-like
|
||||
if (localEnv.Path) res.Path = localEnv.Path; // Windows
|
||||
|
||||
if (color) {
|
||||
res.FORCE_COLOR = `${color}`;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export function interpolateArgsIntoCommand(
|
||||
command: string,
|
||||
opts: Pick<
|
||||
@@ -631,7 +341,10 @@ function filterPropKeysFromUnParsedOptions(
|
||||
|
||||
let registered = false;
|
||||
|
||||
function registerProcessListener() {
|
||||
function registerProcessListener(
|
||||
runningTask: PseudoTtyProcess | ParallelRunningTasks | SeriallyRunningTasks,
|
||||
pseudoTerminal?: PseudoTerminal
|
||||
) {
|
||||
if (registered) {
|
||||
return;
|
||||
}
|
||||
@@ -644,45 +357,27 @@ function registerProcessListener() {
|
||||
pseudoTerminal.sendMessageToChildren(message);
|
||||
}
|
||||
|
||||
childProcesses.forEach((p) => {
|
||||
if ('connected' in p && p.connected) {
|
||||
p.send(message);
|
||||
}
|
||||
});
|
||||
if ('send' in runningTask) {
|
||||
runningTask.send(message);
|
||||
}
|
||||
});
|
||||
|
||||
// Terminate any task processes on exit
|
||||
process.on('exit', () => {
|
||||
childProcesses.forEach((p) => {
|
||||
if ('connected' in p ? p.connected : p.isAlive) {
|
||||
p.kill();
|
||||
}
|
||||
});
|
||||
runningTask.kill();
|
||||
});
|
||||
process.on('SIGINT', () => {
|
||||
childProcesses.forEach((p) => {
|
||||
if ('connected' in p ? p.connected : p.isAlive) {
|
||||
p.kill('SIGTERM');
|
||||
}
|
||||
});
|
||||
runningTask.kill('SIGTERM');
|
||||
// we exit here because we don't need to write anything to cache.
|
||||
process.exit(signalToCode('SIGINT'));
|
||||
});
|
||||
process.on('SIGTERM', () => {
|
||||
childProcesses.forEach((p) => {
|
||||
if ('connected' in p ? p.connected : p.isAlive) {
|
||||
p.kill('SIGTERM');
|
||||
}
|
||||
});
|
||||
runningTask.kill('SIGTERM');
|
||||
// no exit here because we expect child processes to terminate which
|
||||
// will store results to the cache and will terminate this process
|
||||
});
|
||||
process.on('SIGHUP', () => {
|
||||
childProcesses.forEach((p) => {
|
||||
if ('connected' in p ? p.connected : p.isAlive) {
|
||||
p.kill('SIGTERM');
|
||||
}
|
||||
});
|
||||
runningTask.kill('SIGTERM');
|
||||
// no exit here because we expect child processes to terminate which
|
||||
// will store results to the cache and will terminate this process
|
||||
});
|
||||
@@ -705,19 +400,3 @@ function wrapArgIntoQuotesIfNeeded(arg: string): string {
|
||||
return arg;
|
||||
}
|
||||
}
|
||||
|
||||
function isReady(
|
||||
readyWhenStatus: { stringToMatch: string; found: boolean }[] = [],
|
||||
data?: string
|
||||
): boolean {
|
||||
if (data) {
|
||||
for (const readyWhenElement of readyWhenStatus) {
|
||||
if (data.toString().indexOf(readyWhenElement.stringToMatch) > -1) {
|
||||
readyWhenElement.found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return readyWhenStatus.every((readyWhenElement) => readyWhenElement.found);
|
||||
}
|
||||
|
||||