Compare commits

...

19 Commits

Author SHA1 Message Date
JamesHenry 8a872f733e fix(core): main task auto-focus, always allow interactive on terminal outputs 2025-03-19 21:23:03 +04:00
JamesHenry 1dd8ce8995 chore(core): handle resize when hiding tasks list, only auto interactive when continuous 2025-03-18 16:45:24 +04:00
JamesHenry 9cdd5f03f4 Merge branch 'jj' into t 2025-03-18 16:25:58 +04:00
James Henry de0f24a5b0 feat(core): use pseudo_terminal for most tasks (#16) 2025-03-18 16:22:20 +04:00
FrozenPandaz ba22eceb31 feat(core): pin important tasks on initialization 2025-03-17 22:52:30 -04:00
FrozenPandaz eceed8b669 fix(core): fix writing stdin for non tui 2025-03-17 15:46:04 -04:00
JamesHenry aa2a80d7b9 feat(core): use pseudo_terminal for most tasks 2025-03-16 18:33:11 +04:00
JamesHenry 81283b17d9 chore(core): updates 2025-03-07 15:14:18 +04:00
James Henry 0b24a405ed chore(core): cloud 2025-03-06 21:52:55 +04:00
JamesHenry 86c59654b9 chore(core): use vt100 in existing pseudo_terminal 2025-03-05 22:21:14 +04:00
JamesHenry f311341258 chore(repo): cleanup 2025-03-05 22:11:11 +04:00
Colum Ferry 3664c2bbb2 feat(node): generate new apps with continuous: true (#30148)
## Current Behavior
Node applications rely on the `@nx/js:node` executor to handle serving. 
Unfortunately, it can not be determined if existing usage of the
executor is used for a continuous or finite task.

## Expected Behavior
Generate new node applications with `continuous: true` for their serve
targets
2025-02-24 12:02:42 -05:00
Colum Ferry 1dfc8cbbb3 feat(storybook): infer continuous tasks for storybook serve and serve-static (#30069)
## Current Behavior
The `@nx/storybook/plugin` does not set `continuous:true` for serve-like
targets.

## Expected Behavior
The plugin should correctly set `continuous: true` for `serve` and
`serve-static`.
2025-02-20 11:21:34 -05:00
Colum Ferry 62dba7523d feat(vite): infer continuous tasks for dev (#30043)
## Current Behavior
The `dev`, `serve`, `preview` and `serve-static` targets inferred by the
`@nx/vite/plugin` do not infer `continuous:true` indicating to Nx that
these tasks are continuous.


## Expected Behavior
Infer `continuous: true` for the serve-like targets for Vite.
2025-02-20 11:21:33 -05:00
Colum Ferry 1e6e8ad952 feat(webpack): add continuous inference support (#29974)
## Current Behavior
Webpack Inference Plugin does not currently infer `continuous` for
`serve, preview, serve-static` targets.

## Expected Behavior
Webpack Inference Plugin correctly infers `continuous: true`
2025-02-20 11:21:33 -05:00
Colum Ferry d82e7ef41a feat(rspack): add continous inference support (#29973)
## Current Behavior
Rspack and Rsbuild Inference Plugins do not infer `continuous` for serve
tasks.

## Expected Behavior
Correctly infer `continuous` true.
2025-02-20 11:21:32 -05:00
Colum Ferry 0d9f4d24dc feat(js): infer continuous for watch-deps task (#29976)
## Current Behavior
The `@nx/js` plugin exposes a helper to generate `build-deps` and
`watch-deps` tasks for inference plugins.
It does not currently infer `continuous` for the `watch-deps` task.

## Expected Behavior
Ensure `watch-deps` is infered with `continuous: true`
2025-02-20 11:21:32 -05:00
Colum Ferry 38793d71f4 feat(remix): add continuous task support (#29905)
## Current Behavior
When `serve, dev, start` targets are inferred by the `@nx/remix` plugin,
they are not inferring `continuous`.


## Expected Behavior
When `NX_CONTINUOUS_TASKS_PREVIEW` env var exists, infer the
`continuous` property.
2025-02-20 11:21:31 -05:00
Jason Jean 023d4f33e7 feat(core): introduce continuous tasks (#29750)
<!-- Please make sure you have read the submission guidelines before
posting an PR -->
<!--
https://github.com/nrwl/nx/blob/master/CONTRIBUTING.md#-submitting-a-pr
-->

<!-- Please make sure that your commit message follows our format -->
<!-- Example: `fix(nx): must begin with lowercase` -->

<!-- If this is a particularly complex change or feature addition, you
can request a dedicated Nx release for this pull request branch. Mention
someone from the Nx team or the `@nrwl/nx-pipelines-reviewers` and they
will confirm if the PR warrants its own release for testing purposes,
and generate it for you if appropriate. -->

## Summary

An RFC about this feature is happening here: #29025. This has the most
information about this feature.

## Current Behavior
<!-- This is the behavior we have today -->

Nx currently does not explicitly handle tasks which run continuously
until they are terminated.

## Expected Behavior
<!-- This is the behavior we should expect with the changes in this PR
-->

This PR adds the initial support for continuous tasks which run
continuously until they are terminated. This adds the ability to depend
on continuous tasks. There is some more work to be done but this will be
enough as an MVP.

## Related Issue(s)
<!-- Please link the issue being fixed so it gets closed when this is
merged. -->

Fixes #
2025-02-20 11:21:30 -05:00
168 changed files with 10612 additions and 1522 deletions
+4 -14
View File
@@ -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
+16 -16
View File
@@ -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,
Generated
+1277 -66
View File
File diff suppressed because it is too large Load Diff
@@ -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`
+9
View File
@@ -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`
+7
View File
@@ -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`[]\>
+14
View File
@@ -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`
+3 -1
View File
@@ -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`);
+17
View File
@@ -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"]
}
]
}
}
]
}
+41
View File
@@ -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'] },
},
],
});
+8
View File
@@ -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"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+741
View File
@@ -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);
}
+10
View File
@@ -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({});
}
+10
View File
@@ -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();
}
+586
View File
@@ -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,
}
);
});
});
});
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["dom"],
"types": ["node"]
},
"include": [],
"files": [],
"references": [
{
"path": "./tsconfig.spec.json"
}
]
}
+21
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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';
+4 -1
View File
@@ -181,7 +181,10 @@
}
},
"plugins": [
"@monodon/rust",
{
"plugin": "@monodon/rust",
"exclude": ["packages/nx/src/native/tui"]
},
{
"plugin": "@nx/playwright/plugin",
"options": {
+8 -2
View File
@@ -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",
],
+32 -14
View File
@@ -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"
+1
View File
@@ -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
),
};
}
+7
View File
@@ -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);
}

Some files were not shown because too many files have changed in this diff Show More