Skip to content
1 change: 1 addition & 0 deletions packages/@aws-cdk-testing/cli-integ/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from './aws';
export * from './corking';
export * from './integ-test';
export * from './memoize';
export * from './platform';
export * from './resource-pool';
export * from './with-sam';
export * from './shell';
Expand Down
29 changes: 27 additions & 2 deletions packages/@aws-cdk-testing/cli-integ/lib/integ-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,12 +229,37 @@ function slugify(x: string) {
return x.replace(/[^a-zA-Z0-9_,]+/g, '-');
}

async function atomicWrite(fileName: string, contents: string) {
/**
* Write a file by writing to a temp file and renaming it into place.
*
* On POSIX the final rename atomically replaces any existing destination, and
* concurrent writers of the same target harmlessly clobber each other. On
* Windows, replacing a destination that another process currently has open (or
* that is in a "delete pending" state from a concurrent replace) fails with
* EPERM/EACCES. Multiple test workers rewrite shared log files (notably
* `0-header.md`) at once, so ride out that transient window by retrying the
* rename a handful of times before giving up.
*/
export async function atomicWrite(fileName: string, contents: string) {
await fs.promises.mkdir(path.dirname(fileName), { recursive: true });

const tmp = `${fileName}.${process.pid}`;
await fs.promises.writeFile(tmp, contents);
await fs.promises.rename(tmp, fileName);

const maxAttempts = 10;
for (let attempt = 1; ; attempt++) {
try {
await fs.promises.rename(tmp, fileName);
return;
} catch (e: any) {
if (!['EPERM', 'EACCES'].includes(e.code) || attempt >= maxAttempts) {
// Final failure: don't leave the temp file behind as litter.
await fs.promises.rm(tmp, { force: true }).catch(() => undefined);
throw e;
}
await new Promise(ok => setTimeout(ok, Math.floor(Math.random() * 20) + 5));
}
}
}

function readSkipFile(filePath?: string): string[] {
Expand Down
5 changes: 3 additions & 2 deletions packages/@aws-cdk-testing/cli-integ/lib/npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ export async function npmQueryInstalledVersion(packageName: string, dir: string)
* Use NPM preinstalled on the machine to look up a list of TypeScript versions
*/
export function typescriptVersionsSync(): string[] {
const { stdout } = spawnSync('npm', ['--silent', 'view', `typescript@>=${MINIMUM_VERSION}`, 'version', '--json'], { encoding: 'utf-8' });
// Invoke npm through Node: on Windows `npm` is a `.cmd` file, which spawnSync cannot execute directly
const { stdout } = spawnSync(process.execPath, [require.resolve('npm'), '--silent', 'view', `typescript@>=${MINIMUM_VERSION}`, 'version', '--json'], { encoding: 'utf-8' });

const versions: string[] = JSON.parse(stdout);
return Array.from(new Set(versions.map(v => v.split('.').slice(0, 2).join('.'))));
Expand All @@ -50,7 +51,7 @@ export function typescriptVersionsSync(): string[] {
* Use NPM preinstalled on the machine to query publish times of versions
*/
export function typescriptVersionsYoungerThanDaysSync(days: number, versions: string[]): string[] {
const { stdout } = spawnSync('npm', ['--silent', 'view', 'typescript', 'time', '--json'], { encoding: 'utf-8' });
const { stdout } = spawnSync(process.execPath, [require.resolve('npm'), '--silent', 'view', 'typescript', 'time', '--json'], { encoding: 'utf-8' });
const versionTsMap: Record<string, string> = JSON.parse(stdout);

const cutoffDate = new Date(Date.now() - (days * 24 * 3600 * 1000));
Expand Down
6 changes: 6 additions & 0 deletions packages/@aws-cdk-testing/cli-integ/lib/platform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* Whether the current process is running on Windows.
*/
export function isWindows(): boolean {
return process.platform === 'win32';
}
17 changes: 15 additions & 2 deletions packages/@aws-cdk-testing/cli-integ/lib/process.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as child from 'child_process';
import type { Readable, Writable } from 'stream';
import * as pty from 'node-pty';
import { isWindows } from './platform';

/**
* IProcess provides an interface to work with a subprocess.
Expand Down Expand Up @@ -48,11 +49,23 @@ export class Process {
* Spawn a process with a TTY attached.
*/
public static spawnTTY(command: string, args: string[], options: pty.IPtyForkOptions | pty.IWindowsPtyForkOptions = {}): IProcess {
const process = pty.spawn(command, args, {
// ConPTY resolves the spawned file with SearchPath, which only finds real
// executables — not the .cmd shims npm creates for CLI entrypoints. Route
// the command through the shell, like Process.spawn does with 'shell: true'.
if (isWindows()) {
args = ['/c', command, ...args];
command = process.env.ComSpec ?? 'cmd.exe';
}
const ptyProcess = pty.spawn(command, args, {
name: 'xterm-color',
// Wide enough that no output line ever hits the terminal width: ConPTY
// (unlike Unix ptys) renders the screen buffer and inserts hard line
// breaks at the width, which splits long prompts across lines and
// breaks the line-based prompt matching in shell().
cols: 512,
...options,
});
return new PtyProcess(process);
return new PtyProcess(ptyProcess);
}

/**
Expand Down
78 changes: 75 additions & 3 deletions packages/@aws-cdk-testing/cli-integ/lib/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type { TestContext } from './integ-test';
import { isWindows } from './platform';
import { Process } from './process';
import type { TemporaryDirectoryContext } from './with-temporary-directory';

Expand Down Expand Up @@ -282,7 +283,25 @@ export class ShellHelper {
export function rimraf(fsPath: string): boolean {
try {
let success = true;
const isDir = fs.lstatSync(fsPath).isDirectory();
const stat = fs.lstatSync(fsPath);

// This test's private directory contains a 'node_modules' symlink into a
// machine-wide shared install that other running tests also link to. Delete
// the link itself and stop — do NOT recurse through it, or we'd delete the
// shared install's contents out from under those other tests.
if (stat.isSymbolicLink()) {
// On POSIX, unlink removes a symlink whatever its target type. On
// Windows, a link to a directory (or a junction) must be removed with
// rmdir, while a link to a file must be removed with unlink.
if (isWindows() && isDirectoryLink(fsPath)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not:

Suggested change
if (isWindows() && isDirectoryLink(fsPath)) {
if (isWindows() && stats.isDirectory()) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We used lstatsync earlier, which describes the symlink, and not the target. isDirectory needs to operate on the target.

Ref - https://www.geeksforgeeks.org/node-js/node-js-fs-lstatsync-method/
https://www.geeksforgeeks.org/node-js/node-js-stats-isdirectory-method-from-fs-stats-class/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed offline, we are already not recursing if its a symlink to a directory, so we just need to handle the unliking correctly:

      // On POSIX, unlink removes a symlink whatever its target type. On
      // Windows, a link to a directory (or a junction) must be removed with
      // rmdir, while a link to a file must be removed with unlink.
      if (stat.isSymbolicLink() && isWindows() && isDirectoryLink(fsPath)) {
        fs.rmdirSync(fsPath);
      } else {
        fs.unlinkSync(fsPath);
      }

fs.rmdirSync(fsPath);
Comment thread
iliapolo marked this conversation as resolved.
} else {
fs.unlinkSync(fsPath);
}
return true;
}

const isDir = stat.isDirectory();

if (isDir) {
for (const file of fs.readdirSync(fsPath)) {
Expand All @@ -309,14 +328,26 @@ export function rimraf(fsPath: string): boolean {
}
}

/**
* Whether a symlink resolves to a directory.
*
* `statSync` follows the link, so a directory target means a directory link.
* A dangling link (target already removed) returns undefined; treat it as a
* directory, since the only links we create are directory links (the shared
* 'node_modules' junction) and those still need `rmdir` on Windows.
*/
function isDirectoryLink(linkPath: string): boolean {
return fs.statSync(linkPath, { throwIfNoEntry: false })?.isDirectory() ?? true;

@iliapolo iliapolo Aug 25, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is not needed. But in general - is there a good reason why we would call this on a path that doesn't exist?

Agents will always prefer not to throw - make sure you evaluate that decision every time, because this hides very subtle bugs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't understand - when would the path not exist?

@iliapolo iliapolo Aug 25, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly - it should always exist, which is why we should throw

}

export function addToShellPath(x: string) {
const parts = process.env.PATH?.split(':') ?? [];
const parts = process.env.PATH?.split(path.delimiter) ?? [];

if (!parts.includes(x)) {
parts.unshift(x);
}

process.env.PATH = parts.join(':');
process.env.PATH = parts.join(path.delimiter);
}

/**
Expand All @@ -339,7 +370,28 @@ export function addToShellPath(x: string) {
class LastLine {
private lastLine: string = '';

// win32 only: the last completed line that had visible content, see below
private lastVisibleLine: string = '';

public append(chunk: string): void {
if (isWindows()) {
// ConPTY renders the screen buffer instead of streaming plain text:
// prompts are drawn with cursor-positioning escape sequences, padded
// with spaces to the terminal width, and followed by "lines" that
// contain nothing but more escape sequences. Match against the last
// line that had visible content, so control-only lines don't erase a
// prompt that was just drawn.
const lines = stripAnsi(chunk).split(/\r?\n/);
this.lastLine += lines[0];
for (const line of lines.slice(1)) {
if (this.lastLine.trim().length > 0) {
this.lastVisibleLine = this.lastLine;
}
this.lastLine = line;
}
return;
}

const lines = chunk.split(os.EOL);
if (lines.length === 1) {
// chunk doesn't contain a new line so just append
Expand All @@ -351,10 +403,30 @@ class LastLine {
}

public get(): string {
if (isWindows() && this.lastLine.trim().length === 0) {
return this.lastVisibleLine;
}
return this.lastLine;
}

public reset() {
this.lastLine = '';
this.lastVisibleLine = '';
}
}

const ESC = '\u001b';
// CSI sequences (cursor movement, erase, colors) and OSC sequences (window title)
const ANSI_REGEX = new RegExp(`${ESC}\\[[0-9;?]*[@-~]|${ESC}\\][^${ESC}\\u0007]*(?:\\u0007|${ESC}\\\\)`, 'g');

/**
* Remove ANSI escape sequences from terminal output.
*
* Windows ConPTY renders the screen buffer rather than streaming plain text:
* once the cursor reaches the bottom of the buffer, lines arrive as absolute
* cursor-positioning sequences instead of newline-terminated text. Prompt
* matching must look at the text only.
*/
function stripAnsi(chunk: string): string {
return chunk.replace(ANSI_REGEX, '');
}
Loading
Loading