Skip to content
Open
977 changes: 944 additions & 33 deletions .github/workflows/integ.yml

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions .projenrc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1770,6 +1770,10 @@ new CdkCliIntegTestsWorkflow(repo, {
testEnvironment: TEST_ENVIRONMENT,
buildRunsOn: POWERFUL_RUNNER,
testRunsOn: POWERFUL_RUNNER,
// Also run the integ suites on Windows to catch platform-specific
// regressions (paths, subprocess spawning). Uses the free standard runner
// for now; switch to a larger runner label once one is provisioned.
windowsTestRunsOn: 'windows-latest',

allowUpstreamVersions: [
// cloud-assembly-schema gets referenced under multiple versions
Expand Down
30 changes: 28 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,38 @@ 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 RENAME_RETRYABLE = ['EPERM', 'EACCES'];
const maxAttempts = 10;
for (let attempt = 1; ; attempt++) {
try {
await fs.promises.rename(tmp, fileName);
return;
} catch (e: any) {
if (!RENAME_RETRYABLE.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
16 changes: 14 additions & 2 deletions packages/@aws-cdk-testing/cli-integ/lib/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,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 (process.platform === 'win32') {
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
62 changes: 59 additions & 3 deletions packages/@aws-cdk-testing/cli-integ/lib/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,22 @@ export class ShellHelper {
export function rimraf(fsPath: string): boolean {
try {
let success = true;
const isDir = fs.lstatSync(fsPath).isDirectory();
const stat = fs.lstatSync(fsPath);

// Remove links without recursing into their target: a directory may
// link to shared content that other tests are still using (e.g. the
// shared 'node_modules').
if (stat.isSymbolicLink()) {
try {
fs.unlinkSync(fsPath);
} catch {
// On Windows, directory links (junctions) must be removed with rmdir
fs.rmdirSync(fsPath);
}
return true;
}

const isDir = stat.isDirectory();

if (isDir) {
for (const file of fs.readdirSync(fsPath)) {
Expand Down Expand Up @@ -310,13 +325,13 @@ export function rimraf(fsPath: string): boolean {
}

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 +354,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 (process.platform === 'win32') {
// 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 +387,30 @@ class LastLine {
}

public get(): string {
if (process.platform === 'win32' && 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