Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
263 changes: 263 additions & 0 deletions e2e/nx/src/terminal-outputs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
import { existsSync, readFileSync } from 'fs';
import {
cleanupProject,
listFiles,
newProject,
readFile,
runCLI,
uniq,
updateFile,
} from '@nx/e2e-utils';

const TERMINAL_OUTPUTS_DIR = '.nx/cache/terminalOutputs';

describe('terminal outputs on disk', () => {
beforeAll(() => newProject({ packages: [] }));

afterAll(() => cleanupProject());

/**
* Every task that reaches a terminal state has to leave its output at
* `<cacheDir>/terminalOutputs/<hash>`, so the file is found by content
* rather than by recomputing the task's hash.
*/
function terminalOutputContains(marker: string): boolean {
return listFiles(TERMINAL_OUTPUTS_DIR).some((file) =>
readFile(`${TERMINAL_OUTPUTS_DIR}/${file}`).includes(marker)
);
}

function createRunCommandsProject(
lib: string,
marker: string,
cache: boolean
) {
updateFile(
`libs/${lib}/project.json`,
JSON.stringify({
name: lib,
targets: {
echo: {
executor: 'nx:run-commands',
cache,
options: { command: `node -e "console.log('${marker}')"` },
},
},
})
);
}

it('should write terminal output for a cache:false task with --output-style=stream', () => {
const lib = uniq('streamed');
const marker = `streamed-marker-${lib}`;
createRunCommandsProject(lib, marker, false);

const results = runCLI(`echo ${lib} --output-style=stream`);

expect(results).toContain(marker);
expect(terminalOutputContains(marker)).toBe(true);
}, 120000);

it('should write terminal output for a cache:false task in batch mode', () => {
const plugin = uniq('batch-plugin');
const lib = uniq('batched');
const marker = `batched-marker-${lib}`;

// A minimal plugin whose executor supports batching, dropped straight into
// node_modules so it resolves like any installed one. Its batch
// implementation hands each task's output back over IPC and never touches
// disk — exactly the path that used to leave no file behind.
const pluginRoot = `node_modules/${plugin}`;
updateFile(
`${pluginRoot}/package.json`,
JSON.stringify({
name: plugin,
version: '0.0.1',
executors: './executors.json',
})
);
updateFile(
`${pluginRoot}/executors.json`,
JSON.stringify({
executors: {
echo: {
implementation: './impl',
batchImplementation: './batch-impl',
schema: './schema.json',
},
},
})
);
updateFile(
`${pluginRoot}/schema.json`,
JSON.stringify({
$schema: 'http://json-schema.org/schema',
type: 'object',
properties: { text: { type: 'string' } },
})
);
updateFile(
`${pluginRoot}/impl.js`,
`module.exports = {
default: async (options) => {
console.log(options.text);
return { success: true };
},
};`
);
updateFile(
`${pluginRoot}/batch-impl.js`,
`module.exports = {
default: async (taskGraph, inputs) => {
const results = {};
for (const taskId of Object.keys(taskGraph.tasks)) {
results[taskId] = {
success: true,
terminalOutput: inputs[taskId].text,
};
}
return results;
},
};`
);

updateFile(
`libs/${lib}/project.json`,
JSON.stringify({
name: lib,
targets: {
echo: {
executor: `${plugin}:echo`,
cache: false,
options: { text: marker },
},
},
})
);

runCLI(`echo ${lib}`, { env: { NX_BATCH_MODE: 'true' } });

expect(terminalOutputContains(marker)).toBe(true);
}, 120000);

it('should not replay a task whose output was written without artifacts', () => {
const lib = uniq('skipcache');
const marker = `skipcache-marker-${lib}`;
createRunCommandsProject(lib, marker, true);

// --skip-nx-cache leaves a terminal output file, and a record of it so the
// GC can collect it, but writes no cache entry.
const skipped = runCLI(`echo ${lib} --skip-nx-cache`);
expect(skipped).not.toContain('read the output from the cache');
expect(terminalOutputContains(marker)).toBe(true);

// That record must never be served as a hit — there are no outputs behind
// it, so replaying it would restore nothing while reporting success.
const firstRealRun = runCLI(`echo ${lib}`);
expect(firstRealRun).not.toContain('read the output from the cache');

// ...and the real entry it just wrote supersedes the record.
const replay = runCLI(`echo ${lib}`);
expect(replay).toContain('read the output from the cache');
}, 120000);

describe('--output-style=summary', () => {
function createFailingProject(lib: string, marker: string) {
updateFile(
`libs/${lib}/project.json`,
JSON.stringify({
name: lib,
targets: {
echo: {
executor: 'nx:run-commands',
cache: false,
options: {
command: `node -e "console.log('${marker}'); process.exit(3)"`,
},
},
},
})
);
}

function nonEmptyLines(out: string): string[] {
return out.split('\n').filter((l) => l.trim().length > 0);
}

it('should collapse a passing run to a handful of lines', () => {
const lib = uniq('summary-pass');
const marker = `summary-pass-marker-${lib}`;
createRunCommandsProject(lib, marker, false);

const results = runCLI(`echo ${lib} --output-style=summary`);

// The task's own output is on disk, not in the run's output.
expect(results).not.toContain(marker);
expect(results).toContain('succeeded');
expect(nonEmptyLines(results).length).toBeLessThanOrEqual(10);
}, 120000);

it('should name a failing task and point at its log on disk', () => {
const lib = uniq('summary-fail');
const marker = `summary-fail-marker-${lib}`;
createFailingProject(lib, marker);

const results = runCLI(`echo ${lib} --output-style=summary`, {
silenceError: true,
redirectStderr: true,
});

expect(results).toContain('1 failed');
expect(results).toContain(`nx run ${lib}:echo`);
expect(results).toContain('(exit 3)');
// Bounded regardless of how much the task logged.
expect(nonEmptyLines(results).length).toBeLessThanOrEqual(30);

// The path it prints has to be real, and hold the output it stands in for.
const logPath = results.match(/full log: (\S+)/)?.[1];
expect(logPath).toBeDefined();
expect(existsSync(logPath)).toBe(true);
expect(readFileSync(logPath, 'utf-8')).toContain(marker);
}, 120000);

it('should be the default when nx is driven by an AI agent', () => {
const lib = uniq('summary-agent');
const marker = `summary-agent-marker-${lib}`;
createRunCommandsProject(lib, marker, false);

const results = runCLI(`echo ${lib}`, { env: { CLAUDECODE: '1' } });

expect(results).not.toContain(marker);
expect(results).toContain('succeeded');
expect(nonEmptyLines(results).length).toBeLessThanOrEqual(10);
}, 120000);

it('should let an explicit output style beat the AI agent default', () => {
const lib = uniq('summary-explicit');
const marker = `summary-explicit-marker-${lib}`;
createRunCommandsProject(lib, marker, false);

const results = runCLI(`echo ${lib} --output-style=static`, {
env: { CLAUDECODE: '1' },
});

// static prints every task in full, agent or not.
expect(results).toContain(marker);
}, 120000);
});

it('should keep the cached path intact so a replay still reads its output', () => {
const lib = uniq('cached');
const marker = `cached-marker-${lib}`;
createRunCommandsProject(lib, marker, true);

const firstRun = runCLI(`echo ${lib}`);
expect(firstRun).not.toContain('read the output from the cache');
expect(terminalOutputContains(marker)).toBe(true);

const replay = runCLI(`echo ${lib}`);
expect(replay).toContain('read the output from the cache');
// The replay reads this very file, so it must still be there afterwards.
expect(terminalOutputContains(marker)).toBe(true);
}, 120000);
});
44 changes: 44 additions & 0 deletions packages/nx/src/command-line/yargs-utils/shared-options.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,50 @@ describe('shared-options', () => {
}
));

it('should default to summary when driven by an AI agent', async () => {
const { isAiAgent } = require('../../native');
(isAiAgent as jest.Mock).mockReturnValue(true);
try {
await withEnvironmentVariables(
{ NX_TUI: false, CI: 'false', NX_TUI_SKIP_CAPABILITY_CHECK: 'true' },
async () => {
const command = withOutputStyleOption(argv);
const result = await command.parseAsync([]);
expect(result.outputStyle).toEqual('summary');
}
);
} finally {
(isAiAgent as jest.Mock).mockReturnValue(false);
}
});

it('should let an explicit style beat the AI agent default', async () => {
const { isAiAgent } = require('../../native');
(isAiAgent as jest.Mock).mockReturnValue(true);
try {
await withEnvironmentVariables(
{ NX_TUI: false, CI: 'false', NX_TUI_SKIP_CAPABILITY_CHECK: 'true' },
async () => {
const command = withOutputStyleOption(argv);
const result = await command.parseAsync(['--output-style=static']);
expect(result.outputStyle).toEqual('static');
}
);
} finally {
(isAiAgent as jest.Mock).mockReturnValue(false);
}
});

it('should not default to summary when not an AI agent', async () =>
withEnvironmentVariables(
{ NX_TUI: false, CI: 'false', NX_TUI_SKIP_CAPABILITY_CHECK: 'true' },
async () => {
const command = withOutputStyleOption(argv);
const result = await command.parseAsync([]);
expect(result.outputStyle).not.toEqual('summary');
}
));

it('should use NX_DEFAULT_OUTPUT_STYLE if not set', async () =>
withEnvironmentVariables(
{
Expand Down
14 changes: 13 additions & 1 deletion packages/nx/src/command-line/yargs-utils/shared-options.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { readNxJson } from '../../config/nx-json';
import { isAiAgent } from '../../native';
import { shouldUseTui } from '../../tasks-runner/is-tui-enabled';
import { NxArgs } from '../../utils/command-line-utils';
import type { Argv, ParserConfigurationOptions } from 'yargs';
Expand Down Expand Up @@ -330,6 +331,7 @@ const allOutputStyles = [
'dynamic-legacy',
'static',
'static-failures-only',
'summary',
'stream',
'stream-without-prefixes',
] as const;
Expand All @@ -344,13 +346,14 @@ export function withOutputStyleOption<T>(
'tui',
'static',
'static-failures-only',
'summary',
'stream',
'stream-without-prefixes',
]
) {
return yargs
.option('outputStyle', {
describe: `Defines how Nx emits outputs tasks logs. **tui**: enables the Nx Terminal UI, recommended for local development environments. **dynamic-legacy**: use dynamic-legacy output life cycle, previous content is overwritten or modified as new outputs are added, display minimal logs by default, always show errors. This output format is recommended for local development environments where tui is not supported. **static**: uses static output life cycle, no previous content is rewritten or modified as new outputs are added, and every task prints its full output regardless of status. **static-failures-only**: same as **static**, but successful and cached tasks collapse to a single line, so only failing tasks (and, for a single-project run, the project you asked for) print their full output. This is what Nx uses by default in CI and other non-interactive environments. **stream**: nx by default logs output to an internal output stream, enable this option to stream logs to stdout / stderr. **stream-without-prefixes**: nx prefixes the project name the target is running on, use this option remove the project name prefix from output.`,
describe: `Defines how Nx emits outputs tasks logs. **tui**: enables the Nx Terminal UI, recommended for local development environments. **dynamic-legacy**: use dynamic-legacy output life cycle, previous content is overwritten or modified as new outputs are added, display minimal logs by default, always show errors. This output format is recommended for local development environments where tui is not supported. **static**: uses static output life cycle, no previous content is rewritten or modified as new outputs are added, and every task prints its full output regardless of status. **static-failures-only**: same as **static**, but successful and cached tasks collapse to a single line, so only failing tasks (and, for a single-project run, the project you asked for) print their full output. This is what Nx uses by default in CI and other non-interactive environments. **summary**: prints only run counts and one line per failing task, naming the file on disk that holds its full output. No task output is printed inline, so the size of a run's output does not depend on how much its tasks logged. This is the default when Nx detects it is being driven by an AI agent. **stream**: nx by default logs output to an internal output stream, enable this option to stream logs to stdout / stderr. **stream-without-prefixes**: nx prefixes the project name the target is running on, use this option remove the project name prefix from output.`,
type: 'string',
choices,
})
Expand All @@ -364,6 +367,15 @@ export function withOutputStyleOption<T>(
args.outputStyle = process.env.NX_DEFAULT_OUTPUT_STYLE;
}
},
(args) => {
// An agent reads output to find the one failure, so default it to the
// renderer built for that. Assigning here is what selects the summary
// life cycle; the failures-only default is resolved at render time
// instead, so it never reaches the orchestrator's streaming decision.
if (!args.outputStyle && isAiAgent() && choices.includes('summary')) {
args.outputStyle = 'summary';
}
},
(args) => {
const useTui = shouldUseTui(readNxJson(), args as NxArgs);
if (useTui) {
Expand Down
Loading
Loading