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
77 changes: 71 additions & 6 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,15 +283,27 @@ export class ShellHelper {
export function rimraf(fsPath: string): boolean {
try {
let success = true;
const isDir = fs.lstatSync(fsPath).isDirectory();

if (isDir) {
const stat = fs.lstatSync(fsPath);

// `lstat` describes the link itself, not its target, so a symlink is never
// reported as a directory here. That means we never recurse into a symlink's
// target — which may be shared content that other running tests still use
// (e.g. the machine-wide 'node_modules' install) — and only ever remove the
// link entry itself.
if (stat.isDirectory()) {
for (const file of fs.readdirSync(fsPath)) {
success &&= rimraf(path.join(fsPath, file));
}
fs.rmdirSync(fsPath);
} else {
fs.unlinkSync(fsPath);
// A regular file or a symlink. 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);
}
}
return success;
} catch (e: any) {
Expand All @@ -309,14 +322,25 @@ 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.
* The link always points at the live shared install during cleanup, so a
* missing target is not expected: let it throw rather than hide the problem.
*/
function isDirectoryLink(linkPath: string): boolean {
return fs.statSync(linkPath).isDirectory();
}

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 +363,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 +396,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