Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
41dd1ca
feat(cli-integ): also run integ test suites on Windows
iankhou Jul 29, 2026
38bd51c
fix(cli-integ): start Verdaccio via node interpreter for Windows
iankhou Jul 29, 2026
fb6fb39
fix(cli-integ): make test harness work on Windows
iankhou Jul 30, 2026
7065b9f
fix(cli-integ): spawn npm through node in typescript version lookups
iankhou Jul 30, 2026
2f2b1c8
fix(cli-integ): search all stage assemblies for nested template
iankhou Jul 30, 2026
ca02c0d
fix(cli-integ): Windows fixes for docker login env expansion and pyth…
iankhou Jul 30, 2026
9acdeb7
fix(cli-integ): disable wincred credential helper for docker login on…
iankhou Jul 30, 2026
c6deb00
fix(cli-integ): raise init test timeouts to 5 minutes
iankhou Jul 30, 2026
f717d49
fix(cli-integ): write ECR auth directly to docker config on Windows
iankhou Jul 30, 2026
aa2ad26
feat(cli-integ): exclude work directories from Defender on Windows ru…
iankhou Jul 30, 2026
53c5440
fix(cli-integ): skip Linux docker tests on Windows, raise typescript-…
iankhou Jul 30, 2026
d143d4c
feat(cli-integ): replace Defender exclusion with a Dev Drive for TEMP
iankhou Jul 30, 2026
d79a81e
feat(cli-integ): extend Dev Drive to npm cache, grow VHDX to 40GB
iankhou Jul 30, 2026
2c213bc
fix(cli-integ): skip four more Linux-image tests on Windows
iankhou Jul 30, 2026
de77b05
fix(cli-integ): request 4-hour OIDC session for Windows integ jobs
iankhou Jul 30, 2026
5b83d78
revert(cli-integ): back to 1-hour OIDC session for Windows integ jobs
iankhou Jul 30, 2026
0a0e6b1
chore(cli-integ): remove session duration comment
iankhou Jul 30, 2026
caf6fbe
fix(cli-integ): spawn TTY processes through the shell on Windows
iankhou Jul 30, 2026
7c57824
fix(cli-integ): make cdk watch tests work on Windows
iankhou Jul 30, 2026
d124654
fix(cli-integ): deliver Windows skip list via file, add two docker tests
iankhou Jul 30, 2026
34dfbb6
fix(cli-integ): share one npm install across tests on Windows
iankhou Jul 31, 2026
cf3560f
fix(cli-integ): widen ConPTY terminal so long prompts match
iankhou Jul 31, 2026
0cff508
fix(cli-integ): skip sam local test on Windows
iankhou Jul 31, 2026
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
797 changes: 796 additions & 1 deletion .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 @@ -1761,6 +1761,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
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
21 changes: 18 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' on Windows).
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 Down
99 changes: 93 additions & 6 deletions packages/@aws-cdk-testing/cli-integ/lib/with-cdk-app.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable no-console */
import assert from 'assert';
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
Expand Down Expand Up @@ -240,9 +241,10 @@ export interface CdkDestroyCliOptions extends CdkCliOptions {
* Prepare a target dir byreplicating a source directory
*/
export async function cloneDirectory(source: string, target: string, output?: NodeJS.WritableStream) {
await shell(['rm', '-rf', target], { outputs: output ? [output] : [] });
await shell(['mkdir', '-p', target], { outputs: output ? [output] : [] });
await shell(['cp', '-R', source + '/*', target], { outputs: output ? [output] : [] });
output?.write(`Cloning ${source} into ${target}\n`);
await fs.promises.rm(target, { recursive: true, force: true });
await fs.promises.mkdir(target, { recursive: true });
await fs.promises.cp(source, target, { recursive: true });
}

interface CommonCdkBootstrapCommandOptions {
Expand Down Expand Up @@ -395,15 +397,33 @@ export class TestFixture extends ShellHelper {
const tokenResponse = await this.aws.ecrPublic.send(new GetAuthorizationTokenCommand({}));
const authData = tokenResponse.authorizationData?.authorizationToken;

const docker = process.env.CDK_DOCKER ?? 'docker';

if (!authData) {
throw new Error('Could not retrieve ECR public auth token.');
}

if (process.platform === 'win32') {
// `docker login` on Windows stores credentials through the wincred credential
// helper (auto-detected even if `credsStore` is empty in the config file), and
// wincred cannot store ECR tokens: they exceed Windows Credential Manager's
// 2560-byte limit ('The stub received bad data'). Write the auth directly into
// the per-test Docker config file instead, which is exactly what `docker login`
// produces on the Linux runners, where no credential helper is installed.
// The plaintext `auths` entry takes precedence over any credential helper.
await fs.promises.mkdir(this.dockerConfigDir, { recursive: true });
await fs.promises.writeFile(
path.join(this.dockerConfigDir, 'config.json'),
JSON.stringify({ auths: { 'public.ecr.aws': { auth: authData } } }),
);
return;
}

const docker = process.env.CDK_DOCKER ?? 'docker';

const decoded = Buffer.from(authData, 'base64').toString('utf-8');
const [username, password] = decoded.split(':');

// Reference the password via an environment variable so it doesn't leak into
// process listings; the shell expands it.
await this.shell([docker, 'login',
'--username', username,
'--password', '${ECR_PASSWORD}',
Expand Down Expand Up @@ -918,6 +938,70 @@ export async function installNpmPackages(fixture: TestFixture, packages: Record<
devDependencies: packages,
}, undefined, 2), { encoding: 'utf-8' });

if (process.platform === 'win32') {
// Installing aws-cdk-lib means writing out tens of thousands of small
// files, which is very slow on Windows (minutes instead of seconds),
// and every concurrent jest worker doing so at once makes it slower
// still. Install every distinct package set only once per machine and
// junction it into the test directory.
const sharedNodeModules = await sharedPackageSetInstall(fixture, packages);
fs.symlinkSync(sharedNodeModules, path.join(fixture.integTestDir, 'node_modules'), 'junction');
return;
}

await npmInstallWithRetry(fixture, fixture.integTestDir);
}

/**
* Install the given package set into a machine-shared directory, once.
*
* Concurrent callers (jest workers are separate processes) coordinate via an
* atomically-created lock directory; whoever wins installs while the rest
* poll for the completion marker.
*
* @returns the path of the installed `node_modules` directory.
*/
async function sharedPackageSetInstall(fixture: TestFixture, packages: Record<string, string>): Promise<string> {
const hash = crypto.createHash('sha256').update(JSON.stringify(packages)).digest('hex').slice(0, 16);
const sharedDir = path.join(os.tmpdir(), `cdk-integ-shared-${hash}`);
const nodeModules = path.join(sharedDir, 'node_modules');
const completeMarker = path.join(sharedDir, '.install-complete');
const lockDir = `${sharedDir}.lock`;

const deadline = Date.now() + 30 * 60 * 1000;
while (true) {
if (fs.existsSync(completeMarker)) {
return nodeModules;
}
if (Date.now() > deadline) {
throw new Error(`Timed out waiting for shared install of ${JSON.stringify(packages)} in '${sharedDir}'`);
}

try {
fs.mkdirSync(lockDir);
} catch {
// Another worker is installing; wait for it to finish.
await sleep(5_000);
continue;
}

try {
if (fs.existsSync(completeMarker)) {
return nodeModules;
}
fixture.log(`Installing shared package set into '${sharedDir}'`);
fs.mkdirSync(sharedDir, { recursive: true });
fs.copyFileSync(path.join(fixture.integTestDir, 'package.json'), path.join(sharedDir, 'package.json'));
await npmInstallWithRetry(fixture, sharedDir);
fs.writeFileSync(completeMarker, '');
return nodeModules;
} finally {
fs.rmdirSync(lockDir);
}
}
}

async function npmInstallWithRetry(fixture: TestFixture, cwd: string) {
// we often ECONNRESET from NPM so lets retry. this might be because of high concurrency
// which overwhelmes system resources.
const timeoutMinutes = 10;
Expand All @@ -927,7 +1011,10 @@ export async function installNpmPackages(fixture: TestFixture, packages: Record<
while (true) {
try {
// Now install that `package.json` using NPM7
await fixture.shell(['node', require.resolve('npm'), 'install']);
await shell(['node', require.resolve('npm'), 'install'], {
cwd,
outputs: [fixture.output],
});
break;
} catch (e: any) {
if (Date.now() < timeoutDate.getTime() && fixture.output.toString().includes('ECONNRESET' )) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ integTest(
'generating and loading assembly',
withDefaultFixture(async (fixture) => {
const asmOutputDir = `${fixture.integTestDir}-cdk-integ-asm`;
await fixture.shell(['rm', '-rf', asmOutputDir]);
await fs.rm(asmOutputDir, { recursive: true, force: true });

// Synthesize a Cloud Assembly tothe default directory (cdk.out) and a specific directory.
await fixture.cdk(['synth']);
await fixture.cdk(['synth', '--output', asmOutputDir]);

// cdk.out in the current directory and the indicated --output should be the same
await fixture.shell(['diff', 'cdk.out', asmOutputDir]);
await assertDirsEqual(path.join(fixture.integTestDir, 'cdk.out'), asmOutputDir);

// Check that we can 'ls' the synthesized asm.
// Change to some random directory to make sure we're not accidentally loading cdk.json
Expand Down Expand Up @@ -48,3 +48,28 @@ integTest(
}),
);

/**
* Assert that two directories have the same files with the same contents (like `diff -r`)
*/
async function assertDirsEqual(dirA: string, dirB: string) {
const filesA = await relativeFiles(dirA);
const filesB = await relativeFiles(dirB);
expect(filesB).toEqual(filesA);

for (const file of filesA) {
const contentsA = await fs.readFile(path.join(dirA, file), 'utf-8');
const contentsB = await fs.readFile(path.join(dirB, file), 'utf-8');
if (contentsA !== contentsB) {
throw new Error(`File ${file} differs between ${dirA} and ${dirB}`);
}
}
}

async function relativeFiles(root: string): Promise<string[]> {
const entries = await fs.readdir(root, { recursive: true, withFileTypes: true });
return entries
.filter((e) => e.isFile())
.map((e) => path.join(path.relative(root, e.parentPath), e.name))
.sort();
}

Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { promises as fs } from 'fs';
import * as path from 'path';
import { integTest, withDefaultFixture } from '../../../lib';

integTest(
Expand All @@ -7,17 +9,34 @@ integTest(
await fixture.cdk(['synth', '--version-reporting=true']);

// Load template from disk from root assembly
const templateContents = await fixture.shell(['cat', 'cdk.out/*-lambda.template.json']);
const templateContents = await readMatchingFile(path.join(fixture.integTestDir, 'cdk.out'), /^[^\\/]*-lambda\.template\.json$/);

expect(JSON.parse(templateContents).Resources.CDKMetadata).toBeTruthy();

// Load template from nested assembly
const nestedTemplateContents = await fixture.shell([
'cat',
'cdk.out/assembly-*-stage/*StackInStage*.template.json',
]);
// Load template from nested assembly (multiple stage assemblies exist; find the one holding StackInStage)
const nestedTemplate = await findMatchingFile(
path.join(fixture.integTestDir, 'cdk.out'),
/^assembly-.*-stage[\\/].*StackInStage.*\.template\.json$/,
);
const nestedTemplateContents = await fs.readFile(nestedTemplate, 'utf-8');

expect(JSON.parse(nestedTemplateContents).Resources.CDKMetadata).toBeTruthy();
}),
);

/**
* Find a file whose path relative to `root` matches `pattern`, searching recursively (like a shell glob)
*/
async function findMatchingFile(root: string, pattern: RegExp): Promise<string> {
const entries = await fs.readdir(root, { recursive: true, withFileTypes: true });
const match = entries.find((e) => e.isFile() && pattern.test(path.join(path.relative(root, e.parentPath), e.name)));
if (!match) {
throw new Error(`No file matching ${pattern} found in ${root}`);
}
return path.join(match.parentPath, match.name);
}

async function readMatchingFile(root: string, pattern: RegExp): Promise<string> {
return fs.readFile(await findMatchingFile(root, pattern), 'utf-8');
}

Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import * as child_process from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { waitForOutput, waitForCondition, safeKillProcess } from './watch-helpers';
import { waitForOutput, waitForCondition, safeKillProcess, spawnWatch } from './watch-helpers';
import { integTest, withDefaultFixture } from '../../../lib';

jest.setTimeout(5 * 60 * 1000); // 5 minutes for watch tests
Expand Down Expand Up @@ -34,11 +33,10 @@ integTest(
let output = '';

// Start cdk watch
const watchProcess = child_process.spawn('cdk', [
const watchProcess = spawnWatch([
'watch', '--hotswap', '-v', fixture.fullStackName('test-1'),
], {
cwd: fixture.integTestDir,
stdio: 'pipe',
env: { ...process.env, ...fixture.cdkShellEnv() },
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import * as child_process from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { waitForOutput, safeKillProcess } from './watch-helpers';
import { waitForOutput, safeKillProcess, spawnWatch } from './watch-helpers';
import { integTest, withDefaultFixture, sleep } from '../../../lib';

jest.setTimeout(5 * 60 * 1000); // 5 minutes for watch tests
Expand All @@ -27,11 +26,10 @@ integTest(
let output = '';

// Start cdk watch
const watchProcess = child_process.spawn('cdk', [
const watchProcess = spawnWatch([
'watch', '--hotswap', '-v', fixture.fullStackName('test-1'),
], {
cwd: fixture.integTestDir,
stdio: 'pipe',
env: { ...process.env, ...fixture.cdkShellEnv() },
});

Expand Down
Loading
Loading